feat(react): add react hooks to control overlay components (#22484)

This commit is contained in:
Ely Lucas
2021-03-01 09:34:13 -07:00
committed by GitHub
parent dd1c8dbf3b
commit b83e00934e
25 changed files with 1313 additions and 0 deletions

View File

@ -69,6 +69,15 @@ export * from './hrefprops';
// Ionic Animations
export { CreateAnimation } from './CreateAnimation';
// Hooks
export { useIonActionSheet, UseIonActionSheetResult } from '../hooks/useIonActionSheet';
export { useIonAlert, UseIonAlertResult } from '../hooks/useIonAlert';
export { useIonToast, UseIonToastResult } from '../hooks/useIonToast';
export { useIonModal, UseIonModalResult } from '../hooks/useIonModal';
export { useIonPopover, UseIonPopoverResult } from '../hooks/useIonPopover';
export { useIonPicker, UseIonPickerResult } from '../hooks/useIonPicker';
export { useIonLoading, UseIonLoadingResult } from '../hooks/useIonLoading';
// Icons that are used by internal components
addIcons({
'arrow-back-sharp': arrowBackSharp,

View File

@ -0,0 +1,8 @@
import { OverlayEventDetail } from '@ionic/core';
export interface HookOverlayOptions {
onDidDismiss?: (event: CustomEvent<OverlayEventDetail>) => void;
onDidPresent?: (event: CustomEvent<OverlayEventDetail>) => void;
onWillDismiss?: (event: CustomEvent<OverlayEventDetail>) => void;
onWillPresent?: (event: CustomEvent<OverlayEventDetail>) => void;
}

View File

@ -0,0 +1,83 @@
import { OverlayEventDetail } from '@ionic/core';
import { useMemo, useRef } from 'react';
import { attachProps } from '../components/utils';
import { HookOverlayOptions } from './HookOverlayOptions';
interface OverlayBase extends HTMLElement {
present: () => Promise<void>;
dismiss: (data?: any, role?: string | undefined) => Promise<boolean>;
}
export function useController<
OptionsType,
OverlayType extends OverlayBase
>(
displayName: string,
controller: { create: (options: OptionsType) => Promise<OverlayType> }
) {
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]
);
const present = async (options: OptionsType & HookOverlayOptions) => {
if (overlayRef.current) {
return;
}
const {
onDidDismiss,
onWillDismiss,
onDidPresent,
onWillPresent,
...rest
} = options;
const handleDismiss = (event: CustomEvent<OverlayEventDetail<any>>) => {
if (onDidDismiss) {
onDidDismiss(event);
}
overlayRef.current = undefined;
}
overlayRef.current = await controller.create({
...(rest as any),
});
attachProps(overlayRef.current, {
[didDismissEventName]: handleDismiss,
[didPresentEventName]: (e: CustomEvent) =>
onDidPresent && onDidPresent(e),
[willDismissEventName]: (e: CustomEvent) =>
onWillDismiss && onWillDismiss(e),
[willPresentEventName]: (e: CustomEvent) =>
onWillPresent && onWillPresent(e),
});
overlayRef.current.present();
};
const dismiss = async () => {
overlayRef.current && await overlayRef.current.dismiss();
overlayRef.current = undefined;
};
return {
present,
dismiss,
};
}

View File

@ -0,0 +1,53 @@
import { ActionSheetButton, ActionSheetOptions, actionSheetController } from '@ionic/core';
import { HookOverlayOptions } from './HookOverlayOptions';
import { useController } from './useController';
/**
* A hook for presenting/dismissing an IonActionSheet component
* @returns Returns the present and dismiss methods in an array
*/
export function useIonActionSheet(): UseIonActionSheetResult {
const controller = useController<ActionSheetOptions, HTMLIonActionSheetElement>(
'IonActionSheet',
actionSheetController
);
function present(buttons: ActionSheetButton[], header?: string): void;
function present(options: ActionSheetOptions & HookOverlayOptions): void;
function present(buttonsOrOptions: ActionSheetButton[] | ActionSheetOptions & HookOverlayOptions, header?: string) {
if (Array.isArray(buttonsOrOptions)) {
controller.present({
buttons: buttonsOrOptions,
header
});
} else {
controller.present(buttonsOrOptions);
}
}
return [
present,
controller.dismiss
];
}
export type UseIonActionSheetResult = [
{
/**
* Presents the action sheet
* @param buttons An array of buttons for the action sheet
* @param header Optional - Title for the action sheet
*/
(buttons: ActionSheetButton[], header?: string | undefined): void;
/**
* Presents the action sheet
* @param options The options to pass to the IonActionSheet
*/
(options: ActionSheetOptions & HookOverlayOptions): void;
},
/**
* Dismisses the action sheet
*/
() => void
];

View File

@ -0,0 +1,53 @@
import { AlertButton, AlertOptions, alertController } from '@ionic/core';
import { HookOverlayOptions } from './HookOverlayOptions';
import { useController } from './useController';
/**
* A hook for presenting/dismissing an IonAlert component
* @returns Returns the present and dismiss methods in an array
*/
export function useIonAlert(): UseIonAlertResult {
const controller = useController<AlertOptions, HTMLIonAlertElement>(
'IonAlert',
alertController
);
function present(message: string, buttons?: AlertButton[]): void;
function present(options: AlertOptions & HookOverlayOptions): void;
function present(messageOrOptions: string | AlertOptions & HookOverlayOptions, buttons?: AlertButton[]) {
if (typeof messageOrOptions === 'string') {
controller.present({
message: messageOrOptions,
buttons: buttons ?? [{ text: 'Ok' }]
});
} else {
controller.present(messageOrOptions);
}
};
return [
present,
controller.dismiss
];
}
export type UseIonAlertResult = [
{
/**
* Presents the alert
* @param message The main message to be displayed in the alert
* @param buttons Optional - Array of buttons to be added to the alert
*/
(message: string, buttons?: AlertButton[]): void;
/**
* Presents the alert
* @param options The options to pass to the IonAlert
*/
(options: AlertOptions & HookOverlayOptions): void;
},
/**
* Dismisses the alert
*/
() => void
];

View File

@ -0,0 +1,60 @@
import { LoadingOptions, SpinnerTypes, loadingController } from '@ionic/core';
import { HookOverlayOptions } from './HookOverlayOptions';
import { useController } from './useController';
/**
* A hook for presenting/dismissing an IonLoading component
* @returns Returns the present and dismiss methods in an array
*/
export function useIonLoading(): UseIonLoadingResult {
const controller = useController<LoadingOptions, HTMLIonLoadingElement>(
'IonLoading',
loadingController
);
function present(
message?: string,
duration?: number,
spinner?: SpinnerTypes
): void;
function present(options: LoadingOptions & HookOverlayOptions): void;
function present(
messageOrOptions: string | (LoadingOptions & HookOverlayOptions) = '',
duration?: number,
spinner?: SpinnerTypes
) {
if (typeof messageOrOptions === 'string') {
controller.present({
message: messageOrOptions,
duration,
spinner: spinner ?? 'lines',
});
} else {
controller.present(messageOrOptions);
}
}
return [present, controller.dismiss];
}
export type UseIonLoadingResult = [
{
/**
* Presents the loading indicator
* @param message Optional - Text content to display in the loading indicator, defaults to blank string
* @param duration Optional - Number of milliseconds to wait before dismissing the loading indicator
* @param spinner Optional - The name of the spinner to display, defaults to "lines"
*/
(message?: string, duration?: number, spinner?: SpinnerTypes): void;
/**
* Presents the loading indicator
* @param options The options to pass to the IonLoading
*/
(options: LoadingOptions & HookOverlayOptions): void;
},
/**
* Dismisses the loading indicator
*/
() => void
];

View File

@ -0,0 +1,36 @@
import { ModalOptions, modalController } from '@ionic/core';
import { HookOverlayOptions } from './HookOverlayOptions';
import { ReactComponentOrElement, useOverlay } from './useOverlay';
/**
* A hook for presenting/dismissing an IonModal component
* @param component The component that the modal will show. Can be a React Component, a functional component, or a JSX Element
* @param componentProps The props that will be passed to the component, if required
* @returns Returns the present and dismiss methods in an array
*/
export function useIonModal(component: ReactComponentOrElement, componentProps?: any): UseIonModalResult {
const controller = useOverlay<ModalOptions, HTMLIonModalElement>(
'IonModal',
modalController,
component,
componentProps
);
function present(options: Omit<ModalOptions, 'component' | 'componentProps'> & HookOverlayOptions = {}) {
controller.present(options as any);
};
return [
present,
controller.dismiss
];
}
export type UseIonModalResult = [
(options?: Omit<ModalOptions, 'component' | 'componentProps'> & HookOverlayOptions) => void,
/**
* Dismisses the modal
*/
() => void
];

View File

@ -0,0 +1,58 @@
import {
PickerButton,
PickerColumn,
PickerOptions,
pickerController,
} from '@ionic/core';
import { HookOverlayOptions } from './HookOverlayOptions';
import { useController } from './useController';
/**
* A hook for presenting/dismissing an IonPicker component
* @returns Returns the present and dismiss methods in an array
*/
export function useIonPicker(): UseIonPickerResult {
const controller = useController<PickerOptions, HTMLIonPickerElement>(
'IonPicker',
pickerController
);
function present(columns: PickerColumn[], buttons?: PickerButton[]): void;
function present(options: PickerOptions & HookOverlayOptions): void;
function present(
columnsOrOptions: PickerColumn[] | (PickerOptions & HookOverlayOptions),
buttons?: PickerButton[]
) {
if (Array.isArray(columnsOrOptions)) {
controller.present({
columns: columnsOrOptions,
buttons: buttons ?? [{ text: 'Ok' }],
});
} else {
controller.present(columnsOrOptions);
}
}
return [present, controller.dismiss];
}
export type UseIonPickerResult = [
{
/**
* Presents the picker
* @param columns Array of columns to be displayed in the picker.
* @param buttons Optional - Array of buttons to be displayed at the top of the picker.
*/
(columns: PickerColumn[], buttons?: PickerButton[]): void;
/**
* Presents the picker
* @param options The options to pass to the IonPicker
*/
(options: PickerOptions & HookOverlayOptions): void;
},
/**
* Dismisses the picker
*/
() => void
];

View File

@ -0,0 +1,36 @@
import { PopoverOptions, popoverController } from '@ionic/core';
import { HookOverlayOptions } from './HookOverlayOptions';
import { ReactComponentOrElement, useOverlay } from './useOverlay';
/**
* A hook for presenting/dismissing an IonPicker component
* @param component The component that the popover will show. Can be a React Component, a functional component, or a JSX Element
* @param componentProps The props that will be passed to the component, if required
* @returns Returns the present and dismiss methods in an array
*/
export function useIonPopover(component: ReactComponentOrElement, componentProps?: any): UseIonPopoverResult {
const controller = useOverlay<PopoverOptions, HTMLIonPopoverElement>(
'IonPopover',
popoverController,
component,
componentProps
);
function present(options: Omit<PopoverOptions, 'component' | 'componentProps'> & HookOverlayOptions = {}) {
controller.present(options as any);
};
return [
present,
controller.dismiss
];
}
export type UseIonPopoverResult = [
(options?: Omit<PopoverOptions, 'component' | 'componentProps'> & HookOverlayOptions) => void,
/**
* Dismisses the popover
*/
() => void
];

View File

@ -0,0 +1,53 @@
import { ToastOptions, toastController } from '@ionic/core';
import { HookOverlayOptions } from './HookOverlayOptions';
import { useController } from './useController';
/**
* A hook for presenting/dismissing an IonToast component
* @returns Returns the present and dismiss methods in an array
*/
export function useIonToast(): UseIonToastResult {
const controller = useController<ToastOptions, HTMLIonToastElement>(
'IonToast',
toastController
);
function present(message: string, duration?: number): void;
function present(options: ToastOptions & HookOverlayOptions): void;
function present(messageOrOptions: string | ToastOptions & HookOverlayOptions, duration?: number) {
if (typeof messageOrOptions === 'string') {
controller.present({
message: messageOrOptions,
duration
});
} else {
controller.present(messageOrOptions);
}
};
return [
present,
controller.dismiss
];
}
export type UseIonToastResult = [
{
/**
* Presents the toast
* @param message Message to be shown in the toast.
* @param duration Optional - How many milliseconds to wait before hiding the toast. By default, it will show until dismissToast() is called.
*/
(message: string, duration?: number): void;
/**
* Presents the Toast
* @param options The options to pass to the IonToast.
*/
(options: ToastOptions & HookOverlayOptions): void;
},
/**
* Dismisses the toast
*/
() => void
];

View File

@ -0,0 +1,111 @@
import { OverlayEventDetail } from '@ionic/core';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { attachProps } from '../components/utils';
import { HookOverlayOptions } from './HookOverlayOptions';
export type ReactComponentOrElement = React.ComponentClass<any, any> | React.FC<any> | JSX.Element;
interface OverlayBase extends HTMLElement {
present: () => Promise<void>;
dismiss: (data?: any, role?: string | undefined) => Promise<boolean>;
}
export function useOverlay<
OptionsType,
OverlayType extends OverlayBase
>(
displayName: string,
controller: { create: (options: OptionsType) => Promise<OverlayType>; },
component: ReactComponentOrElement,
componentProps?: any
) {
const overlayRef = useRef<OverlayType>();
const containerElRef = useRef<HTMLDivElement>();
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]
);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (isOpen && component && containerElRef.current) {
if (React.isValidElement(component)) {
ReactDOM.render(component, containerElRef.current);
} else {
ReactDOM.render(React.createElement(component as React.ComponentClass, componentProps), containerElRef.current);
}
}
}, [component, containerElRef.current, isOpen, componentProps]);
const present = async (options: OptionsType & HookOverlayOptions) => {
if (overlayRef.current) {
return;
}
const {
onDidDismiss,
onWillDismiss,
onDidPresent,
onWillPresent,
...rest
} = options;
if (typeof document !== 'undefined') {
containerElRef.current = document.createElement('div');
}
overlayRef.current = await controller.create({
...(rest as any),
component: containerElRef.current
});
attachProps(overlayRef.current, {
[didDismissEventName]: handleDismiss,
[didPresentEventName]: (e: CustomEvent) =>
onDidPresent && onDidPresent(e),
[willDismissEventName]: (e: CustomEvent) =>
onWillDismiss && onWillDismiss(e),
[willPresentEventName]: (e: CustomEvent) =>
onWillPresent && onWillPresent(e),
});
overlayRef.current.present();
setIsOpen(true);
function handleDismiss(event: CustomEvent<OverlayEventDetail<any>>) {
if (onDidDismiss) {
onDidDismiss(event);
}
overlayRef.current = undefined;
containerElRef.current = undefined;
setIsOpen(false);
}
};
const dismiss = async () => {
overlayRef.current && await overlayRef.current.dismiss();
overlayRef.current = undefined;
containerElRef.current = undefined;
};
return {
present,
dismiss,
};
}