Skip to content

Commit

Permalink
fix(angular, react, vue): overlays no longer throw errors when used i…
Browse files Browse the repository at this point in the history
…nside tests (#24681)

resolves #24549, resolves #24590
  • Loading branch information
liamdebeasi committed Feb 2, 2022
1 parent b0c9f09 commit 897ae4a
Show file tree
Hide file tree
Showing 12 changed files with 94 additions and 74 deletions.
44 changes: 40 additions & 4 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,55 @@ The `@ionic/core` package can be used in simple HTML, or by vanilla JavaScript w

In addition to the default, self lazy-loading components built by Stencil, this package also comes with each component exported as a stand-alone custom element within `@ionic/core/components`. Each component extends `HTMLElement`, and does not lazy-load itself. Instead, this package is useful for projects already using a bundler such as Webpack or Rollup. While all components are available to be imported, the custom elements build also ensures bundlers only import what's used, and tree-shakes any unused components.

Below is an example of importing `ion-toggle`, and initializing Ionic so it's able to correctly load the "mode", such as Material Design or iOS. Additionally, the `initialize({...})` function can receive the Ionic config.
Below is an example of importing `ion-badge`, and initializing Ionic so it is able to correctly load the "mode", such as Material Design or iOS. Additionally, the `initialize({...})` function can receive the Ionic config.

```typescript
import { IonBadge } from "@ionic/core/components/ion-badge";
import { defineCustomElement } from "@ionic/core/components/ion-badge.js";
import { initialize } from "@ionic/core/components";

// Initializes the Ionic config and `mode` behavior
initialize();

customElements.define("ion-badge", IonBadge);
// Defines the `ion-badge` web component
defineCustomElement();
```

Notice how `IonBadge` is imported from `@ionic/core/components/ion-badge` rather than just `@ionic/core/components`. Additionally, the `initialize` function is imported from `@ionic/core/components` rather than `@ionic/core`. All of this helps to ensure bundlers do not pull in more code than is needed.
Notice how we import from `@ionic/core/components` as opposed to `@ionic/core`. This helps bundlers pull in only the code that is needed.

The `defineCustomElement` function will automatically define the component as well as any child components that may be required.

For example, if you wanted to use `ion-modal`, you would do the following:

```typescript
import { defineCustomElement } from "@ionic/core/components/ion-modal.js";
import { initialize } from "@ionic/core/components";

// Initializes the Ionic config and `mode` behavior
initialize();

// Defines the `ion-modal` and child `ion-backdrop` web components.
defineCustomElement();
```

The `defineCustomElement` function will define `ion-modal`, but it will also define `ion-backdrop`, which is a component that `ion-modal` uses internally.

### Using Overlay Controllers

When using an overlay controller, developers will need to define the overlay component before it can be used. Below is an example of using `modalController`:

```typescript
import { defineCustomElement } from '@ionic/core/components/ion-modal.js';
import { initialize, modalController } from '@ionic/core/components';

initialize();
defineCustomElement();

const showModal = async () => {
const modal = await modalController.create({ ... });

...
}
```

## How to contribute

Expand Down
57 changes: 11 additions & 46 deletions core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,3 @@
import { ActionSheet } from '../components/action-sheet/action-sheet';
import { Alert } from '../components/alert/alert';
import { Backdrop } from '../components/backdrop/backdrop';
import { Loading } from '../components/loading/loading';
import { Modal } from '../components/modal/modal';
import { PickerColumnCmp } from '../components/picker-column/picker-column';
import { Picker } from '../components/picker/picker';
import { Popover } from '../components/popover/popover';
import { RippleEffect } from '../components/ripple-effect/ripple-effect';
import { Spinner } from '../components/spinner/spinner';
import { Toast } from '../components/toast/toast';
import { config } from '../global/config';
import { getIonMode } from '../global/ionic-global';
import { ActionSheetOptions, AlertOptions, Animation, AnimationBuilder, BackButtonEvent, HTMLIonOverlayElement, IonicConfig, LoadingOptions, ModalOptions, OverlayInterface, PickerOptions, PopoverOptions, ToastOptions } from '../interface';
Expand All @@ -20,15 +9,10 @@ let lastId = 0;

export const activeAnimations = new WeakMap<OverlayInterface, Animation[]>();

type ChildCustomElementDefinition = {
tagName: string;
customElement: any;
}

const createController = <Opts extends object, HTMLElm extends any>(tagName: string, customElement?: any, childrenCustomElements?: ChildCustomElementDefinition[]) => {
const createController = <Opts extends object, HTMLElm extends any>(tagName: string) => {
return {
create(options: Opts): Promise<HTMLElm> {
return createOverlay(tagName, options, customElement, childrenCustomElements) as any;
return createOverlay(tagName, options) as any;
},
dismiss(data?: any, role?: string, id?: string) {
return dismissOverlay(document, data, role, tagName, id);
Expand All @@ -39,13 +23,13 @@ const createController = <Opts extends object, HTMLElm extends any>(tagName: str
};
};

export const alertController = /*@__PURE__*/createController<AlertOptions, HTMLIonAlertElement>('ion-alert', Alert, [{ tagName: 'ion-backdrop', customElement: Backdrop }]);
export const actionSheetController = /*@__PURE__*/createController<ActionSheetOptions, HTMLIonActionSheetElement>('ion-action-sheet', ActionSheet, [{ tagName: 'ion-backdrop', customElement: Backdrop }, { tagName: 'ion-ripple-effect', customElement: RippleEffect }]);
export const loadingController = /*@__PURE__*/createController<LoadingOptions, HTMLIonLoadingElement>('ion-loading', Loading, [{ tagName: 'ion-backdrop', customElement: Backdrop }, { tagName: 'ion-spinner', customElement: Spinner }]);
export const modalController = /*@__PURE__*/createController<ModalOptions, HTMLIonModalElement>('ion-modal', Modal, [{ tagName: 'ion-backdrop', customElement: Backdrop }]);
export const pickerController = /*@__PURE__*/createController<PickerOptions, HTMLIonPickerElement>('ion-picker', Picker, [{ tagName: 'ion-picker-column', customElement: PickerColumnCmp }, { tagName: 'ion-backdrop', customElement: Backdrop }]);
export const popoverController = /*@__PURE__*/createController<PopoverOptions, HTMLIonPopoverElement>('ion-popover', Popover, [{ tagName: 'ion-backdrop', customElement: Backdrop }]);
export const toastController = /*@__PURE__*/createController<ToastOptions, HTMLIonToastElement>('ion-toast', Toast, [{ tagName: 'ion-ripple-effect', customElement: RippleEffect }]);
export const alertController = /*@__PURE__*/createController<AlertOptions, HTMLIonAlertElement>('ion-alert');
export const actionSheetController = /*@__PURE__*/createController<ActionSheetOptions, HTMLIonActionSheetElement>('ion-action-sheet');
export const loadingController = /*@__PURE__*/createController<LoadingOptions, HTMLIonLoadingElement>('ion-loading');
export const modalController = /*@__PURE__*/createController<ModalOptions, HTMLIonModalElement>('ion-modal');
export const pickerController = /*@__PURE__*/createController<PickerOptions, HTMLIonPickerElement>('ion-picker');
export const popoverController = /*@__PURE__*/createController<PopoverOptions, HTMLIonPopoverElement>('ion-popover');
export const toastController = /*@__PURE__*/createController<ToastOptions, HTMLIonToastElement>('ion-toast');

export interface OverlayListenerOptions {
trapKeyboardFocus: boolean;
Expand All @@ -65,29 +49,10 @@ export const prepareOverlay = <T extends HTMLIonOverlayElement>(el: T, options:
}
};

const registerOverlayComponents = (tagName: string, customElement: any, childrenCustomElements?: ChildCustomElementDefinition[]): Promise<any> => {
const { customElements } = window;
if (!customElements.get(tagName)) {
customElements.define(tagName, customElement);
}
/**
* If the parent element has nested usage of custom elements,
* we need to manually define those custom elements.
*/
if (childrenCustomElements) {
for (const customElementDefinition of childrenCustomElements) {
if (!customElements.get(customElementDefinition.tagName)) {
customElements.define(customElementDefinition.tagName, customElementDefinition.customElement);
}
}
}
return customElements.whenDefined(tagName);
}

export const createOverlay = <T extends HTMLIonOverlayElement>(tagName: string, opts: object | undefined, customElement?: any, childrenCustomElements?: ChildCustomElementDefinition[]): Promise<T> => {
export const createOverlay = <T extends HTMLIonOverlayElement>(tagName: string, opts: object | undefined): Promise<T> => {
/* tslint:disable-next-line */
if (typeof window !== 'undefined' && typeof window.customElements !== 'undefined') {
return registerOverlayComponents(tagName, customElement, childrenCustomElements).then(() => {
return window.customElements.whenDefined(tagName).then(() => {
const element = document.createElement(tagName) as HTMLIonOverlayElement;
element.classList.add('overlay-hidden');

Expand Down
6 changes: 5 additions & 1 deletion packages/react/src/hooks/useController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,23 @@ interface OverlayBase extends HTMLElement {

export function useController<OptionsType, OverlayType extends OverlayBase>(
displayName: string,
controller: { create: (options: OptionsType) => Promise<OverlayType> }
controller: { create: (options: OptionsType) => Promise<OverlayType> },
defineCustomElement: () => void
) {
const overlayRef = useRef<OverlayType>();
const didDismissEventName = useMemo(() => `on${displayName}DidDismiss`, [displayName]);
const didPresentEventName = useMemo(() => `on${displayName}DidPresent`, [displayName]);
const willDismissEventName = useMemo(() => `on${displayName}WillDismiss`, [displayName]);
const willPresentEventName = useMemo(() => `on${displayName}WillPresent`, [displayName]);

defineCustomElement();

const present = useCallback(
async (options: OptionsType & HookOverlayOptions) => {
if (overlayRef.current) {
return;
}

const { onDidDismiss, onWillDismiss, onDidPresent, onWillPresent, ...rest } = options;

const handleDismiss = (event: CustomEvent<OverlayEventDetail<any>>) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/hooks/useIonActionSheet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ActionSheetButton, ActionSheetOptions, actionSheetController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-action-sheet.js';
import { useCallback } from 'react';

import { HookOverlayOptions } from './HookOverlayOptions';
Expand All @@ -11,7 +12,8 @@ import { useController } from './useController';
export function useIonActionSheet(): UseIonActionSheetResult {
const controller = useController<ActionSheetOptions, HTMLIonActionSheetElement>(
'IonActionSheet',
actionSheetController
actionSheetController,
defineCustomElement
);

const present = useCallback(
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/hooks/useIonAlert.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AlertButton, AlertOptions, alertController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-alert.js';
import { useCallback } from 'react';

import { HookOverlayOptions } from './HookOverlayOptions';
Expand All @@ -9,7 +10,7 @@ import { useController } from './useController';
* @returns Returns the present and dismiss methods in an array
*/
export function useIonAlert(): UseIonAlertResult {
const controller = useController<AlertOptions, HTMLIonAlertElement>('IonAlert', alertController);
const controller = useController<AlertOptions, HTMLIonAlertElement>('IonAlert', alertController, defineCustomElement);

const present = useCallback(
(messageOrOptions: string | (AlertOptions & HookOverlayOptions), buttons?: AlertButton[]) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/hooks/useIonLoading.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LoadingOptions, SpinnerTypes, loadingController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-loading.js';
import { useCallback } from 'react';

import { HookOverlayOptions } from './HookOverlayOptions';
Expand All @@ -11,7 +12,8 @@ import { useController } from './useController';
export function useIonLoading(): UseIonLoadingResult {
const controller = useController<LoadingOptions, HTMLIonLoadingElement>(
'IonLoading',
loadingController
loadingController,
defineCustomElement
);

const present = useCallback(
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/hooks/useIonModal.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ModalOptions, modalController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-modal.js';
import { useCallback } from 'react';

import { ReactComponentOrElement } from '../models/ReactComponentOrElement';
Expand All @@ -19,6 +20,7 @@ export function useIonModal(
const controller = useOverlay<ModalOptions, HTMLIonModalElement>(
'IonModal',
modalController,
defineCustomElement,
component,
componentProps
);
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/hooks/useIonPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
PickerOptions,
pickerController,
} from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-picker.js';
import { useCallback } from 'react';

import { HookOverlayOptions } from './HookOverlayOptions';
Expand All @@ -16,7 +17,8 @@ import { useController } from './useController';
export function useIonPicker(): UseIonPickerResult {
const controller = useController<PickerOptions, HTMLIonPickerElement>(
'IonPicker',
pickerController
pickerController,
defineCustomElement
);

const present = useCallback((
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/hooks/useIonPopover.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PopoverOptions, popoverController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-popover.js';
import { useCallback } from 'react';

import { ReactComponentOrElement } from '../models/ReactComponentOrElement';
Expand All @@ -16,6 +17,7 @@ export function useIonPopover(component: ReactComponentOrElement, componentProps
const controller = useOverlay<PopoverOptions, HTMLIonPopoverElement>(
'IonPopover',
popoverController,
defineCustomElement,
component,
componentProps
);
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/hooks/useIonToast.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ToastOptions, toastController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-toast.js';
import { useCallback } from 'react';

import { HookOverlayOptions } from './HookOverlayOptions';
Expand All @@ -11,7 +12,8 @@ import { useController } from './useController';
export function useIonToast(): UseIonToastResult {
const controller = useController<ToastOptions, HTMLIonToastElement>(
'IonToast',
toastController
toastController,
defineCustomElement
);

const present = useCallback((messageOrOptions: string | ToastOptions & HookOverlayOptions, duration?: number) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/hooks/useOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface OverlayBase extends HTMLElement {
export function useOverlay<OptionsType, OverlayType extends OverlayBase>(
displayName: string,
controller: { create: (options: OptionsType) => Promise<OverlayType> },
defineCustomElement: () => void,
component: ReactComponentOrElement,
componentProps?: any
) {
Expand All @@ -29,6 +30,8 @@ export function useOverlay<OptionsType, OverlayType extends OverlayBase>(
const ionContext = useContext(IonContext);
const [overlayId] = useState(generateId('overlay'));

defineCustomElement();

useEffect(() => {
if (isOpen && component && containerElRef.current) {
if (React.isValidElement(component)) {
Expand Down
35 changes: 17 additions & 18 deletions packages/vue/src/controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,29 @@ import {
pickerController as pickerCtrl,
toastController as toastCtrl,
} from '@ionic/core/components';
import { defineCustomElement } from './utils';

import { VueDelegate } from './framework-delegate';

import { IonModal } from '@ionic/core/components/ion-modal.js';
import { IonPopover } from '@ionic/core/components/ion-popover.js'
import { IonAlert } from '@ionic/core/components/ion-alert.js'
import { IonActionSheet } from '@ionic/core/components/ion-action-sheet.js'
import { IonLoading } from '@ionic/core/components/ion-loading.js'
import { IonPicker } from '@ionic/core/components/ion-picker.js'
import { IonToast } from '@ionic/core/components/ion-toast.js'
import { defineCustomElement as defineIonActionSheetCustomElement } from '@ionic/core/components/ion-action-sheet.js'
import { defineCustomElement as defineIonAlertCustomElement } from '@ionic/core/components/ion-alert.js'
import { defineCustomElement as defineIonLoadingCustomElement } from '@ionic/core/components/ion-loading.js'
import { defineCustomElement as defineIonPickerCustomElement } from '@ionic/core/components/ion-picker.js'
import { defineCustomElement as defineIonToastCustomElement } from '@ionic/core/components/ion-toast.js'
import { defineCustomElement as defineIonModalCustomElement } from '@ionic/core/components/ion-modal.js'
import { defineCustomElement as defineIonPopoverCustomElement } from '@ionic/core/components/ion-popover.js'

/**
* Wrap the controllers export from @ionic/core
* register the underlying Web Component and
* (optionally) provide a framework delegate.
*/
const createController: {
<T>(tagName: string, customElement: any, oldController: T, useDelegate?: boolean): T
} = (tagName: string, customElement: any, oldController: any, useDelegate = false) => {
<T>(defineCustomElement: () => void, oldController: T, useDelegate?: boolean): T
} = (defineCustomElement: () => void, oldController: any, useDelegate = false) => {
const delegate = useDelegate ? VueDelegate() : undefined;
const oldCreate = oldController.create.bind(oldController);
oldController.create = (options: any) => {
defineCustomElement(tagName, customElement);
defineCustomElement();

return oldCreate({
...options,
Expand All @@ -41,13 +40,13 @@ const createController: {
return oldController;
}

const modalController = /*@__PURE__*/ createController('ion-modal', IonModal, modalCtrl, true);
const popoverController = /*@__PURE__*/ createController('ion-popover', IonPopover, popoverCtrl, true);
const alertController = /*@__PURE__*/ createController('ion-alert', IonAlert, alertCtrl);
const actionSheetController = /*@__PURE__*/ createController('ion-action-sheet', IonActionSheet, actionSheetCtrl);
const loadingController = /*@__PURE__*/ createController('ion-loading', IonLoading, loadingCtrl);
const pickerController = /*@__PURE__*/ createController('ion-picker', IonPicker, pickerCtrl);
const toastController = /*@__PURE__*/ createController('ion-toast', IonToast, toastCtrl);
const modalController = /*@__PURE__*/ createController(defineIonModalCustomElement, modalCtrl, true);
const popoverController = /*@__PURE__*/ createController(defineIonPopoverCustomElement, popoverCtrl, true);
const alertController = /*@__PURE__*/ createController(defineIonAlertCustomElement, alertCtrl);
const actionSheetController = /*@__PURE__*/ createController(defineIonActionSheetCustomElement, actionSheetCtrl);
const loadingController = /*@__PURE__*/ createController(defineIonLoadingCustomElement, loadingCtrl);
const pickerController = /*@__PURE__*/ createController(defineIonPickerCustomElement, pickerCtrl);
const toastController = /*@__PURE__*/ createController(defineIonToastCustomElement, toastCtrl);

export {
modalController,
Expand Down

0 comments on commit 897ae4a

Please sign in to comment.