necolas/react-native-web

Modal: implementation

necolas opened this issue ยท 17 comments

Accessible modal implementation (in progress):

  • Hides app content from screen-readers when active.
  • Traps focus.
  • Built-in Escape-to-close mechanism.

https://facebook.github.io/react-native/docs/modal

https://github.com/rayandrews/react-native-web-modal

I'm creating this modal, inspired by kiurchv and react native's modal and based on react native web's components (not css).

Actually the real repo is:
https://github.com/rayandrews/react-native-web-modal/tree/master/packages/modal-react-native-web

escape key etc not implemented.

what do you think @necolas?

DaKaZ commented

All, I do cross-platform development in react-native and providing accessible web pages is required for my work. I have spent the last year doing a few different implementations of Modal that meet both for functionality and accessibility. Thanks to @RayAndrews for sharing the modal-react-native-web and linking to the enhanced modal implementation: https://github.com/davidtheclark/focus-trap-react I now have a solution that is fully working cross platform including web. However, I did have to add a small react package: https://github.com/davidtheclark/focus-trap-react in order to fully trap focus.

For reference, my implementation looks like this:

  render(): Element<*> {
    const modalContent = Platform.OS === 'web' ? (
      <FocusTrap>
        <ScopeSelect hideModal={this._toggleModal} headingLevel={1} />
      </FocusTrap>
    ) : (
      <ScopeSelect hideModal={this._toggleModal} headingLevel={1} />
    );
    return (
      <View style={Styles.container}>        
        <Modal
          isVisible={this.state.isModalVisible}
          onBackdropPress={this._toggleModal}
        >
          <View style={styles.modalWrapper}>
            {this.state.isModalVisible && modalContent}
          </View>
        </Modal>
        
        <TabsScreen
          navigation={this.props.navigation}
          screenProps={this.props.screenProps}
          onTabChange={this._onTabsScreenTabChange}
          showActivity={this.props.showActivity}
          hideActivity={this.props.hideActivity}
        />
      </View>
    );
  }

I built a library to manage layers https://github.com/giuseppeg/react-layers-manager let me know if you want to adopt it for this and if you think that the API needs improvements.

If somebody wants to use a custom implementation like modal-enhanced-react-native-web or any other file, just set up a webpack alias:

resolve: {
  alias: {
    'react-native$': 'react-native-web',
    'react-native-web/dist/exports/Modal': 'modal-enhanced-react-native-web',
    ...
  }
}

This requires that you use the babel-plugin-react-native-web in your .babelrc.

@RayAndrews 's implementation looks good.

@necolas I would like to take a stab at this. Here is how I would go about implementing this. In the description you said that this "(in progress)" though, is that accurate?

I made a temporary implementation that has:

  • Hides app content from screen-readers when active.
  • Traps focus.
  • Built-in Escape-to-close mechanism.

But lacks:

  • Animations
  • Properly working back button handling using useHistory

https://wetrustplatform.github.io/paramount/#/src-components-modal-modal

DaKaZ commented

We had improved our solution from before, just reposting it here in case anyone wants to use it:

// @flow
/**
 * EnhancedModal renders a modal for Web or Native with animations and trapped focus (on web)
 * Implementation Note: all children components will receive the props `modalHeight` and
 * `modalWidth` which represents the visible area of the modal after layout.
 *
 * For full list of available props see:
 * https://github.com/Dekoruma/react-native-web-modal/blob/master/packages/modal-enhanced-react-native-web/src/index.js
 */
import React from 'react';
import { Platform } from 'react-native';
import Modal from 'react-native-modal';
import type { Element } from 'react';
import type { LayoutEvent } from 'react-native/Libraries/Types/CoreEventTypes';

type PropsType = {
  /* react provides all children to this component */
  children: Element<*> | Array<Element<*>>,
  /* enable FocusTrap on web (default: true) */
  focusTrap: boolean,
  /* visibility state of modal (required) */
  isVisible: boolean
};

type StateType = {
  modalHeight: number,
  modalWidth: number
};

class EnhancedModal extends React.PureComponent<PropsType, StateType> {
  static defaultProps = {
    focusTrap: true
  };

  state = {
    modalHeight: 0,
    modalWidth: 0
  };

  _modalLayout = (e: LayoutEvent) => {
    const { height, width } = e.nativeEvent.layout;
    if (height || width) {
      this.setState({
        modalHeight: height,
        modalWidth: width
      });
    }
  };

  _decorateChildren = (children: Element<*> | Array<*>): Array<*> => (
    React.Children.map(children, (child: Element<*>): Element<*> =>
      React.cloneElement(child, { ...this.state }))
  );

  _wrap = (kids: Array<*>): Element<*> => {
    const FocusTrap = require('focus-trap-react');
    return <FocusTrap>{kids}</FocusTrap>;
  };

  render(): Element<*> {
    const {
      children,
      focusTrap,
      isVisible,
      ...modalProps
    } = this.props;
    return (
      <Modal
        {...modalProps}
        isVisible={isVisible}
        onLayout={this._modalLayout}
      >
        {isVisible
          && focusTrap
          && Platform.OS === 'web'
          && this._wrap(this._decorateChildren(children)) }
        {isVisible
          && !(focusTrap
          && Platform.OS === 'web')
          && this._decorateChildren(children) }
      </Modal>
    );
  }
}

export default EnhancedModal;

And remember in your webpack config to:

  resolve: {
    alias: {
      "react-native": "react-native-web",
      "react-native-modal": "modal-enhanced-react-native-web"
    }
  },

There's another implementation here:

https://github.com/Dekoruma/react-native-web-modal

That one also mentioned yet another one here:

https://github.com/Dekoruma/react-native-web-modal/tree/master/packages/modal-enhanced-react-native-web

Perhaps one of those could be brought into this libe to simplify deps?

It would be nice if the Modal API moved out of React Native and web support fell under the React Navigation umbrella.

emac3 commented

so what'd you suggest for a universal Modal @EvanBacon ? Next React Navigation for Web would support this? I guess showing a modal is not the same as navigating to a page (handling back/forward buttons, url paths, etc.). What about using portals and/or context? react-native-modal/react-native-modal#261

kopax commented

I am just reposting my feature request #1629 bellow

Is your feature request related to a problem? Please describe.

support import { Modal } from 'react-native; in react-native-web

Describe a solution you'd like

I want to be able to use the official modal in react native web

Describe alternatives you've considered

Using react-native-paper modal, but it does not animate at all like in the official modal demo

Additional context

This is a core component of react-native and unfortunately, it is not compatible with react-native-web so we have to look for alternatives (do you have some)

This is react-native official <Modal /> documentation:

https://reactnative.dev/docs/modal.html

They have a live demo here

image

The demo is for iOS and Android, I want to use react-native-web to target the web devices.

This is the same live demo used on the web: https://snack.expo.io/@kopax/excited-bagel

As you can see, the modal is not hidden, and animation does not trigger when clicking on hide/show

image

By the way, the modal does not include a clickable backdrop overlay. The react-native-paper version of the modal include one but then we can't animate the modal. I haven't found any solution to this yet that can target iOS/Android and the web, if anyone know the alternatives, it would be nice for others like me to list them here.

Support's the list you mentioned + animations

@necolas -> #1698

Add modal-enhanced-react-native-web into web/aliases/react-native/index.js used by react-app-rewired can work well too.

npm install --save-dev react-app-rewired modal-enhanced-react-native-web

Create a config-overrides.js in your project root

// used by react-app-rewired

const webpack = require('webpack');
const path = require('path');

module.exports = {
  webpack: function (config, env) {
    config.module.rules[1].use[0].options.baseConfig.extends = [
      path.resolve('.eslintrc.js'),
    ];

    // To let alias like 'react-native/Libraries/Components/StaticRenderer'
    // take effect, must set it before alias 'react-native'
    delete config.resolve.alias['react-native'];
    config.resolve.alias['react-native/Libraries/Components/StaticRenderer'] =
      'react-native-web/dist/vendor/react-native/StaticRenderer';
    config.resolve.alias['react-native'] = path.resolve(
      'web/aliases/react-native',
    );

    // Let's force our code to bundle using the same bundler react native does.
    config.plugins.push(
      new webpack.DefinePlugin({
        __DEV__: env === 'development',
      }),
    );

    // Need this rule to prevent `Attempted import error: 'SOME' is not exported from` when `react-app-rewired build`
    // Need this rule to prevent `TypeError: Cannot assign to read only property 'exports' of object` when `react-app-rewired start`
    config.module.rules.push({
      test: /\.(js|tsx?)$/,
      // You can exclude the exclude property if you don't want to keep adding individual node_modules
      // just keep an eye on how it effects your build times, for this example it's negligible
      // exclude: /node_modules[/\\](?!@react-navigation|react-native-gesture-handler|react-native-screens)/,
      use: {
        loader: 'babel-loader',
      },
    });

    return config;
  },
  paths: function (paths, env) {
    paths.appIndexJs = path.resolve('index.web.js');
    paths.appSrc = path.resolve('.');
    paths.moduleFileExtensions.push('ios.js');
    return paths;
  },
};

Also create a web/aliases/react-native/index.js

// ref to https://levelup.gitconnected.com/react-native-typescript-and-react-native-web-an-arduous-but-rewarding-journey-8f46090ca56b

import {Text as RNText, Image as RNImage} from 'react-native-web';
import RNModal from 'modal-enhanced-react-native-web';
// Let's export everything from react-native-web
export * from 'react-native-web';

// And let's stub out everything that's missing!
export const ViewPropTypes = {
  style: () => {},
};
RNText.propTypes = {
  style: () => {},
};
RNImage.propTypes = {
  style: () => {},
  source: () => {},
};

export const Text = RNText;
export const Image = RNImage;
export const Modal = RNModal;
// export const ToolbarAndroid = {};
export const requireNativeComponent = () => {};

Now you can just run react-app-rewired start instead of react-scripts start

Could you publish Modal component