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
This commit is contained in:
Manu MA
2019-08-27 16:29:37 +02:00
committed by GitHub
parent a65d897214
commit 48a27636c7
33 changed files with 411 additions and 368 deletions

View File

@ -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,6 +14,7 @@ export class App implements ComponentInterface {
@Element() el!: HTMLElement;
componentDidLoad() {
if (Build.isBrowser) {
rIC(() => {
const isHybrid = isPlatform(window, 'hybrid');
if (!config.getBoolean('_testing')) {
@ -29,9 +30,9 @@ export class App implements ComponentInterface {
import('../../utils/hardware-back-button').then(module => module.startHardwareBackButton());
}
import('../../utils/focus-visible').then(module => module.startFocusVisible());
});
}
}
render() {
const mode = getIonMode(this);

View File

@ -39,14 +39,14 @@ export class Backdrop implements ComponentInterface {
*/
@Event() ionBackdropTap!: EventEmitter<void>;
componentDidLoad() {
connectedCallback() {
if (this.stopPropagation) {
this.blocker.block();
}
}
componentDidUnload() {
this.blocker.destroy();
disconnectedCallback() {
this.blocker.unblock();
}
@Listen('touchstart', { passive: false, capture: true })

View File

@ -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<ScrollBaseDetail>;
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)}

View File

@ -76,11 +76,13 @@ export class InfiniteScroll implements ComponentInterface {
*/
@Event() ionInfinite!: EventEmitter<void>;
async componentDidLoad() {
async connectedCallback() {
const contentEl = this.el.closest('ion-content');
if (contentEl) {
this.scrollEl = await contentEl.getScrollElement();
if (!contentEl) {
console.error('<ion-infinite-scroll> must be used inside an <ion-content>');
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;
}

View File

@ -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<StyleEventDetail>;
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;
}
}

View File

@ -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;

View File

@ -136,7 +136,7 @@ export class Menu implements ComponentInterface, MenuI {
*/
@Event() protected ionMenuChange!: EventEmitter<MenuChangeEventDetail>;
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) {

View File

@ -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) {

View File

@ -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<RadioGroupChangeEventDetail>;
@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<HTMLIonRadioElement>(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 {
<Host
role="radiogroup"
aria-labelledby={this.labelId}
onIonSelect={this.onSelect}
onIonDeselect={this.allowEmptySelection ? this.onDeselect : undefined}
class={getIonMode(this)}
>
</Host>

View File

@ -22,7 +22,7 @@
<ion-content class="outer-content">
<ion-radio-group id="dynamicDisabled" disabled name="tannen" id="group" value="biff">
<ion-list-header>
<ion-label>Luckiest Man On Earth</ion-label>
<ion-label>Luckiest Man On Earth <span id="group-value"></span></ion-label>
</ion-list-header>
<ion-item>
@ -53,7 +53,10 @@
</ion-radio-group>
<ion-button onClick="toggleDisabled()">Toggle Disabled</ion-button>
<ion-button onClick="addSelect()">Add Select</ion-button>
<ion-button onClick="addCheckedSelect()">Add Checked Select</ion-button>
<ion-button onClick="removeSelect()">Remove Select</ion-button>
</ion-content>
<style>
@ -61,12 +64,39 @@
--background: #f2f2f2;
}
</style>
<script>
var dynamicDisabled = document.getElementById('dynamicDisabled');
let count = 0;
const valueEl = document.querySelector('#group-value');
const group = document.querySelector('ion-radio-group');
group.addEventListener('ionChange', (ev) => {
valueEl.textContent = group.value;
});
customElements.whenDefined('ion-radio-group')
.then(() => group.componentOnReady())
.then(() => {
valueEl.textContent = group.value;
});
function toggleDisabled() {
dynamicDisabled.disabled = !dynamicDisabled.disabled;
function addSelect() {
const item = document.createElement('ion-item');
item.innerHTML = `
<ion-label>Item ${count}</ion-label>
<ion-radio value="item-${count}" slot="start"></ion-radio>
`;
group.appendChild(item);
count++;
}
function addCheckedSelect() {
const item = document.createElement('ion-item');
item.innerHTML = `
<ion-label>Item ${count}</ion-label>
<ion-radio value="item-${count}" slot="start" checked></ion-radio>
`;
group.appendChild(item);
count++;
}
function removeSelect() {
group.children[group.children.length - 1].remove();
}
</script>
</ion-app>

View File

@ -49,18 +49,6 @@ export class Radio implements ComponentInterface {
*/
@Prop({ mutable: true }) value?: any | null;
/**
* Emitted when the radio loads.
* @internal
*/
@Event() ionRadioDidLoad!: EventEmitter<void>;
/**
* Emitted when the radio unloads.
* @internal
*/
@Event() ionRadioDidUnload!: EventEmitter<void>;
/**
* 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,

View File

@ -169,13 +169,11 @@ export class Range implements ComponentInterface {
*/
@Event() ionBlur!: EventEmitter<void>;
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;

View File

@ -97,20 +97,19 @@ export class Refresher implements ComponentInterface {
*/
@Event() ionStart!: EventEmitter<void>;
async componentDidLoad() {
async connectedCallback() {
if (this.el.getAttribute('slot') !== 'fixed') {
console.error('Make sure you use: <ion-refresher slot="fixed">');
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('<ion-refresher> must be used inside an <ion-content>');
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();

View File

@ -52,12 +52,13 @@ export class ReorderGroup implements ComponentInterface {
*/
@Event() ionItemReorder!: EventEmitter<ItemReorderEventDetail>;
async componentDidLoad() {
async connectedCallback() {
const contentEl = this.el.closest('ion-content');
if (contentEl) {
this.scrollEl = await contentEl.getScrollElement();
if (!contentEl) {
console.error('<ion-reorder-group> must be used inside an <ion-content>');
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();

View File

@ -45,10 +45,7 @@ export class RouteRedirect implements ComponentInterface {
this.ionRouteRedirectChanged.emit();
}
componentDidLoad() {
this.ionRouteRedirectChanged.emit();
}
componentDidUnload() {
connectedCallback() {
this.ionRouteRedirectChanged.emit();
}
}

View File

@ -58,10 +58,7 @@ export class Route implements ComponentInterface {
}
}
componentDidLoad() {
this.ionRouteDataChanged.emit();
}
componentDidUnload() {
connectedCallback() {
this.ionRouteDataChanged.emit();
}
}

View File

@ -60,11 +60,7 @@ export class RouterOutlet implements ComponentInterface, NavOutlet {
/** @internal */
@Event({ bubbles: false }) ionNavDidChange!: EventEmitter<void>;
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;

View File

@ -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();
});

View File

@ -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<void>;
/**
* Emitted when the select option unloads.
* @internal
*/
@Event() ionSelectOptionDidUnload!: EventEmitter<void>;
componentWillLoad() {
if (this.value === undefined) {
this.value = this.el.textContent || '';
}
}
componentDidLoad() {
this.ionSelectOptionDidLoad.emit();
}
componentDidUnload() {
this.ionSelectOptionDidUnload.emit();
}
@Prop() value?: any | null;
render() {
return (

View File

@ -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<StyleEventDetail>;
@Watch('disabled')
@Watch('placeholder')
disabledChanged() {
this.emitStyle();
}
@Watch('value')
valueChanged() {
if (this.didInit) {
this.updateOptions();
this.emitStyle();
if (this.didInit) {
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<HTMLIonSelectOptionElement>(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

View File

@ -25,6 +25,7 @@
<ion-label position="floating">Label with Placeholder</ion-label>
<ion-select id="actionSheet" interface="action-sheet" placeholder="A Placeholder"></ion-select>
</ion-item>
<script>
let selects = document.querySelectorAll('ion-select');
const options = ['bird', 'dog', 'shark', 'lizard'];

View File

@ -1,5 +1,4 @@
import { Component, ComponentInterface, Event, Host, h } from '@stencil/core';
import { EventEmitter } from 'ionicons/dist/types/stencil.core';
import { Component, ComponentInterface, Host, h } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global';
@ -9,17 +8,6 @@ import { getIonMode } from '../../global/ionic-global';
})
export class Slide implements ComponentInterface {
/** @internal */
@Event() ionSlideChanged!: EventEmitter<void>;
componentDidLoad() {
this.ionSlideChanged.emit();
}
componentDidUnload() {
this.ionSlideChanged.emit();
}
render() {
const mode = getIonMode(this);
return (

View File

@ -1,4 +1,4 @@
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Method, Prop, Watch, h } from '@stencil/core';
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, Watch, h } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global';
import { rIC } from '../../utils/helpers.js';
@ -20,8 +20,8 @@ export class Slides implements ComponentInterface {
private scrollbarEl?: HTMLElement;
private paginationEl?: HTMLElement;
private didInit = false;
private swiperReady = false;
private mutationO?: MutationObserver;
private readySwiper!: (swiper: SwiperInterface) => void;
private swiper: Promise<SwiperInterface> = new Promise(resolve => { this.readySwiper = resolve; });
@ -35,7 +35,7 @@ export class Slides implements ComponentInterface {
@Watch('options')
async optionsChanged() {
if (this.didInit) {
if (this.swiperReady) {
const swiper = await this.getSwiper();
Object.assign(swiper.params, this.options);
await this.update();
@ -132,20 +132,28 @@ export class Slides implements ComponentInterface {
*/
@Event() ionSlideTouchEnd!: EventEmitter<void>;
componentDidLoad() {
connectedCallback() {
const mut = this.mutationO = new MutationObserver(() => {
if (this.swiperReady) {
this.update();
}
});
mut.observe(this.el, {
childList: true,
subtree: true
});
rIC(() => this.initSwiper());
}
async componentDidUnload() {
async disconnectedCallback() {
if (this.mutationO) {
this.mutationO.disconnect();
this.mutationO = undefined;
}
const swiper = await this.getSwiper();
swiper.destroy(true, true);
}
@Listen('ionSlideChanged')
onSlideChanged() {
if (this.didInit) {
this.update();
}
this.swiper = new Promise(resolve => { this.readySwiper = resolve; });
this.swiperReady = false;
}
/**
@ -154,7 +162,10 @@ export class Slides implements ComponentInterface {
*/
@Method()
async update() {
const swiper = await this.getSwiper();
const [swiper] = await Promise.all([
this.getSwiper(),
waitForSlides(this.el)
]);
swiper.update();
}
@ -315,8 +326,9 @@ export class Slides implements ComponentInterface {
// init swiper core
// @ts-ignore
const { Swiper } = await import('./swiper/swiper.bundle.js');
await waitForSlides(this.el);
const swiper = new Swiper(this.el, finalOptions);
this.didInit = true;
this.swiperReady = true;
this.readySwiper(swiper);
}
@ -485,3 +497,9 @@ export class Slides implements ComponentInterface {
);
}
}
const waitForSlides = (el: HTMLElement) => {
return Promise.all(
Array.from(el.querySelectorAll('ion-slide')).map(s => s.componentOnReady())
);
};

View File

@ -33,6 +33,9 @@
<h1>Slide 3</h1>
</ion-slide>
</ion-slides>
<ion-button expand="block" onclick="addSlide()">Add slide</ion-button>
<ion-button expand="block" onclick="removeSlide()">Remove slide</ion-button>
<ion-button expand="block" onclick="slidePrev()">Slide Prev</ion-button>
<ion-button expand="block" onclick="slideNext()">Slide Next</ion-button>
<ion-button expand="block" onclick="getActiveIndex()">Get Active Index</ion-button>
@ -50,10 +53,24 @@
</ion-app>
<script>
let slideCount = 4;
const slides = document.getElementById('slides')
slides.pager = false;
slides.options = {}
async function addSlide() {
const slide = document.createElement('ion-slide');
slide.style.background = 'white';
slide.innerHTML = `<h1>Slide ${slideCount}</h1>`;
slideCount++;
slides.querySelector('.swiper-wrapper').appendChild(slide);
};
async function removeSlide() {
const wraper = slides.querySelector('.swiper-wrapper');
wraper.children[wraper.children.length-1].remove();
};
async function slideNext() {
await slides.slideNext(500)
};

View File

@ -57,12 +57,12 @@ export class SplitPane implements ComponentInterface {
this.ionSplitPaneVisible.emit(detail);
}
componentDidLoad() {
connectedCallback() {
this.styleChildren();
this.updateState();
}
componentDidUnload() {
disconnectedCallback() {
if (this.rmL) {
this.rmL();
this.rmL = undefined;

View File

@ -64,7 +64,6 @@
<ion-content class="ion-padding">
<h1>Page 1</h1>
<ion-button onclick="push()">Push</ion-button>
<ion-button onclick="menu()">Disable/enable menu</ion-button>
<f></f>
<f></f>
@ -84,9 +83,6 @@
menuCtrl.open('start');
}
function push() {
}
async function menu() {
menuCtrl.enable(!await menuCtrl.isEnabled());
}

View File

@ -31,7 +31,6 @@ export class Tab implements ComponentInterface {
@Prop() component?: ComponentRef;
componentWillLoad() {
if (Build.isDev) {
if (this.component !== undefined && this.el.childElementCount > 0) {
console.error('You can not use a lazy-loaded component in a tab and inlined content at the same time.' +

View File

@ -19,7 +19,6 @@ export class Tabs implements NavOutlet {
@Element() el!: HTMLIonTabsElement;
@State() tabs: HTMLIonTabElement[] = [];
@State() selectedTab?: HTMLIonTabElement;
/** @internal */
@ -41,23 +40,18 @@ export class Tabs implements NavOutlet {
*/
@Event({ bubbles: false }) ionTabsDidChange!: EventEmitter<{tab: string}>;
componentWillLoad() {
async componentWillLoad() {
if (!this.useRouter) {
this.useRouter = !!document.querySelector('ion-router') && !this.el.closest('[no-router]');
}
this.tabs = Array.from(this.el.querySelectorAll('ion-tab'));
this.initSelect().then(() => {
if (!this.useRouter) {
const tabs = this.tabs;
await this.select(tabs[0]);
}
this.ionNavWillLoad.emit();
this.componentWillUpdate();
});
}
componentDidUnload() {
this.tabs.length = 0;
this.selectedTab = this.leavingTab = undefined;
}
componentWillUpdate() {
componentWillRender() {
const tabBar = this.el.querySelector('ion-tab-bar');
if (tabBar) {
const tab = this.selectedTab ? this.selectedTab.tab : undefined;
@ -131,16 +125,6 @@ export class Tabs implements NavOutlet {
return tabId !== undefined ? { id: tabId, element: this.selectedTab } : undefined;
}
private async initSelect(): Promise<void> {
if (this.useRouter) {
return;
}
// wait for all tabs to be ready
await Promise.all(this.tabs.map(tab => tab.componentOnReady()));
await this.select(this.tabs[0]);
}
private setActive(selectedTab: HTMLIonTabElement): Promise<void> {
if (this.transitioning) {
return Promise.reject('transitioning already happening');
@ -186,16 +170,19 @@ export class Tabs implements NavOutlet {
return selectedTab !== undefined && selectedTab !== leavingTab && !this.transitioning;
}
private get tabs() {
return Array.from(this.el.querySelectorAll('ion-tab'));
}
private onTabClicked = (ev: CustomEvent<TabButtonClickEventDetail>) => {
const { href, tab } = ev.detail;
const selectedTab = this.tabs.find(t => t.tab === tab);
if (this.useRouter && href !== undefined) {
const router = document.querySelector('ion-router');
if (router) {
router.push(href);
}
} else if (selectedTab) {
this.select(selectedTab);
} else {
this.select(tab);
}
}

View File

@ -1,4 +1,4 @@
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, readTask } from '@stencil/core';
import { Build, Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, readTask } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global';
import { Color, StyleEventDetail, TextareaChangeEventDetail } from '../../interface';
@ -169,28 +169,26 @@ export class Textarea implements ComponentInterface {
*/
@Event() ionFocus!: EventEmitter<void>;
/**
* Emitted when the input has been created.
* @internal
*/
@Event() ionInputDidLoad!: EventEmitter<void>;
/**
* Emitted when the input has been removed.
* @internal
*/
@Event() ionInputDidUnload!: EventEmitter<void>;
componentWillLoad() {
connectedCallback() {
this.emitStyle();
this.debounceChanged();
if (Build.isBrowser) {
this.el.dispatchEvent(new CustomEvent('ionInputDidLoad', {
detail: this.el
}));
}
}
disconnectedCallback() {
if (Build.isBrowser) {
document.dispatchEvent(new CustomEvent('ionInputDidUnload', {
detail: this.el
}));
}
}
componentDidLoad() {
this.debounceChanged();
this.runAutoGrow();
this.ionInputDidLoad.emit();
}
// TODO: performance hit, this cause layout thrashing
@ -204,10 +202,6 @@ export class Textarea implements ComponentInterface {
}
}
componentDidUnload() {
this.ionInputDidUnload.emit();
}
/**
* Sets focus on the specified `ion-textarea`. Use this method instead of the global
* `input.focus()`.

View File

@ -96,11 +96,7 @@ export class Toggle implements ComponentInterface {
}
}
componentWillLoad() {
this.emitStyle();
}
async componentDidLoad() {
async connectedCallback() {
this.gesture = (await import('../../utils/gesture')).createGesture({
el: this.el,
gestureName: 'toggle',
@ -114,13 +110,17 @@ export class Toggle implements ComponentInterface {
this.disabledChanged();
}
componentDidUnload() {
disconnectedCallback() {
if (this.gesture) {
this.gesture.destroy();
this.gesture = undefined;
}
}
componentWillLoad() {
this.emitStyle();
}
private emitStyle() {
this.ionStyle.emit({
'interactive-disabled': this.disabled,

View File

@ -152,16 +152,14 @@ export class VirtualScroll implements ComponentInterface {
this.updateVirtualScroll();
}
async componentDidLoad() {
async connectedCallback() {
const contentEl = this.el.closest('ion-content');
if (!contentEl) {
console.error('virtual-scroll must be used inside ion-content');
console.error('<ion-virtual-scroll> must be used inside an <ion-content>');
return;
}
await contentEl.componentOnReady();
this.contentEl = contentEl;
this.scrollEl = await contentEl.getScrollElement();
this.contentEl = contentEl;
this.calcCells();
this.updateState();
}
@ -170,7 +168,7 @@ export class VirtualScroll implements ComponentInterface {
this.updateState();
}
componentDidUnload() {
disconnectedCallback() {
this.scrollEl = undefined;
}

View File

@ -75,11 +75,13 @@ export const startInputShims = (config: Config) => {
registerInput(input);
}
doc.body.addEventListener('ionInputDidLoad', event => {
registerInput(event.target as any);
});
doc.addEventListener('ionInputDidLoad', ((ev: InputEvent) => {
registerInput(ev.detail);
}) as any);
doc.body.addEventListener('ionInputDidUnload', event => {
unregisterInput(event.target as any);
});
doc.addEventListener('ionInputDidUnload', ((ev: InputEvent) => {
unregisterInput(ev.detail);
}) as any);
};
type InputEvent = CustomEvent<HTMLElement>;

View File

@ -0,0 +1,32 @@
export const watchForOptions = <T extends HTMLElement>(containerEl: HTMLElement, tagName: string, onChange: (el: T | undefined) => void) => {
const mutation = new MutationObserver(mutationList => {
onChange(getSelectedOption<T>(mutationList, tagName));
});
mutation.observe(containerEl, {
childList: true,
subtree: true
});
return mutation;
};
const getSelectedOption = <T extends HTMLElement>(mutationList: MutationRecord[], tagName: string) => {
let newOption: T | undefined;
mutationList.forEach(mut => {
// tslint:disable-next-line: prefer-for-of
for (let i = 0; i < mut.addedNodes.length; i++) {
newOption = findCheckedOption(mut.addedNodes[i], tagName) || newOption;
}
});
return newOption;
};
export const findCheckedOption = (el: any, tagName: string) => {
if (el.nodeType !== 1) {
return undefined;
}
const options = (el.tagName === tagName.toUpperCase())
? [el]
: Array.from(el.querySelectorAll(tagName));
return options.find((o: any) => o.checked === true);
};