[RN] Add branded dialog component

This commit is contained in:
Bettenbuk Zoltan 2018-10-18 10:28:08 +02:00 committed by Saúl Ibarra Corretgé
parent 3ebad112a2
commit 22a602768c
27 changed files with 760 additions and 88 deletions

View File

@ -266,6 +266,8 @@
},
"allow": "Allow",
"confirm": "Confirm",
"confirmNo": "No",
"confirmYes": "Yes",
"kickMessage": "Ouch! You have been kicked out of the meet!",
"kickTitle": "Kicked from meeting",
"popupErrorTitle": "Pop-up blocked",

View File

@ -4,7 +4,7 @@ import React, { Component } from 'react';
import { Container, Text } from '../../react';
import { dialog as styles } from './styles';
import styles from './styles';
type Props = {

View File

@ -0,0 +1,3 @@
// @flow
export * from './native';

View File

@ -0,0 +1,3 @@
// @flow
export * from './web';

View File

@ -1,10 +1,6 @@
// @flow
export { default as BottomSheet } from './BottomSheet';
export { default as Dialog } from './Dialog';
export * from './_';
export { default as DialogContainer } from './DialogContainer';
export { default as DialogContent } from './DialogContent';
export { default as StatelessDialog } from './StatelessDialog';
export { default as DialogWithTabs } from './DialogWithTabs';
export { default as AbstractDialogTab } from './AbstractDialogTab';
export type { Props as AbstractDialogTabProps } from './AbstractDialogTab';

View File

@ -0,0 +1,139 @@
// @flow
import React from 'react';
import {
Text,
TouchableOpacity,
View
} from 'react-native';
import { Icon } from '../../../font-icons';
import AbstractDialog, {
type Props as AbstractProps,
type State
} from '../AbstractDialog';
import { brandedDialog as styles } from './styles';
export type Props = {
...AbstractProps,
t: Function
}
/**
* Component to render a custom dialog.
*/
class BaseDialog<P: Props, S: State> extends AbstractDialog<P, S> {
/**
* Initializes a new {@code FeedbackDialog} instance.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { style } = this.props;
return (
<View
pointerEvents = 'box-none'
style = { [
styles.overlay,
style
] }>
<View
pointerEvents = 'box-none'
style = { [
styles.dialog,
this.props.style
] }>
<TouchableOpacity
onPress = { this._onCancel }
style = { styles.closeWrapper }>
<Icon
name = 'close'
style = { styles.closeStyle } />
</TouchableOpacity>
{ this._renderContent() }
</View>
</View>
);
}
_onCancel: () => void;
_onSubmit: () => boolean;
/**
* Renders the content of the dialog.
*
* @returns {ReactElement}
*/
_renderContent: () => Object
/**
* Renders a specific {@code string} which may contain HTML.
*
* @param {string|undefined} html - The {@code string} which may
* contain HTML to render.
* @returns {ReactElement[]|string}
*/
_renderHTML(html: ?string) {
if (typeof html === 'string') {
// At the time of this writing, the specified HTML contains a couple
// of spaces one after the other. They do not cause a visible
// problem on Web, because the specified HTML is rendered as, well,
// HTML. However, we're not rendering HTML here.
// eslint-disable-next-line no-param-reassign
html = html.replace(/\s{2,}/gi, ' ');
// Render text in <b>text</b> in bold.
const opening = /<\s*b\s*>/gi;
const closing = /<\s*\/\s*b\s*>/gi;
let o;
let c;
let prevClosingLastIndex = 0;
const r = [];
// eslint-disable-next-line no-cond-assign
while (o = opening.exec(html)) {
closing.lastIndex = opening.lastIndex;
// eslint-disable-next-line no-cond-assign
if (c = closing.exec(html)) {
r.push(html.substring(prevClosingLastIndex, o.index));
r.push(
<Text style = { styles.boldDialogText }>
{ html.substring(opening.lastIndex, c.index) }
</Text>);
opening.lastIndex
= prevClosingLastIndex
= closing.lastIndex;
} else {
break;
}
}
if (prevClosingLastIndex < html.length) {
r.push(html.substring(prevClosingLastIndex));
}
return r;
}
return html;
}
}
export default BaseDialog;

View File

@ -0,0 +1,93 @@
// @flow
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import BaseDialog, { type Props as BaseProps } from './BaseDialog';
import {
brandedDialog
} from './styles';
type Props = {
...BaseProps,
t: Function
}
/**
* Abstract dialog to submit something. E.g. a confirmation or a form.
*/
class BaseSubmitDialog<P: Props, S: *> extends BaseDialog<P, S> {
/**
* Returns the title key of the submit button.
*
* NOTE: Please do not change this, this should be consistent accross the
* application. This method is here to be able to be overriden ONLY by the
* {@code ConfirmDialog}.
*
* @returns {string}
*/
_getSubmitButtonKey() {
return 'dialog.Ok';
}
/**
* Renders additional buttons, if any - may be overwritten by children.
*
* @returns {?ReactElement}
*/
_renderAdditionalButtons() {
return null;
}
/**
* Implements {@code BaseDialog._renderContent}.
*
* @inheritdoc
*/
_renderContent() {
const { t } = this.props;
const additionalButtons = this._renderAdditionalButtons();
return (
<View>
<View style = { brandedDialog.mainWrapper }>
{ this._renderSubmittable() }
</View>
<View style = { brandedDialog.buttonWrapper }>
{ additionalButtons }
<TouchableOpacity
disabled = { this.props.okDisabled }
onPress = { this._onSubmit }
style = { [
brandedDialog.button,
additionalButtons
? null : brandedDialog.buttonFarLeft,
brandedDialog.buttonFarRight
] }>
<Text style = { brandedDialog.text }>
{ t(this._getSubmitButtonKey()) }
</Text>
</TouchableOpacity>
</View>
</View>
);
}
_onCancel: () => void;
_onSubmit: ?string => boolean;
_renderHTML: string => Object | string
/**
* Renders the actual content of the dialog defining what is about to be
* submitted. E.g. a simple confirmation (text, properly wrapped) or a
* complex form.
*
* @returns {Object}
*/
_renderSubmittable: () => Object
}
export default BaseSubmitDialog;

View File

@ -0,0 +1,91 @@
// @flow
import React from 'react';
import { Text, TouchableOpacity } from 'react-native';
import { connect } from 'react-redux';
import { translate } from '../../../i18n';
import { type Props as BaseProps } from './BaseDialog';
import BaseSubmitDialog from './BaseSubmitDialog';
import { brandedDialog } from './styles';
type Props = {
...BaseProps,
/**
* Untranslated i18n key of the content to be displayed.
*
* NOTE: This dialog also adds support to Object type keys that will be
* translated using the provided params. See i18n function
* {@code translate(string, Object)} for more details.
*/
contentKey: string | { key: string, params: Object},
t: Function
}
/**
* Implements a confirm dialog component.
*/
class ConfirmDialog extends BaseSubmitDialog<Props, *> {
/**
* Returns the title key of the submit button.
*
* @returns {string}
*/
_getSubmitButtonKey() {
return 'dialog.confirmYes';
}
_onCancel: () => void;
/**
* Renders the 'No' button.
*
* NOTE: The {@code ConfirmDialog} is the only dialog right now that
* renders 2 buttons, mainly for clarity.
*
* @inheritdoc
*/
_renderAdditionalButtons() {
const { t } = this.props;
return (
<TouchableOpacity
onPress = { this._onCancel }
style = { [
brandedDialog.button,
brandedDialog.buttonFarLeft,
brandedDialog.buttonSeparator
] }>
<Text style = { brandedDialog.text }>
{ t('dialog.confirmNo') }
</Text>
</TouchableOpacity>
);
}
/**
* Implements {@code BaseSubmitDialog._renderSubmittable}.
*
* @inheritdoc
*/
_renderSubmittable() {
const { contentKey, t } = this.props;
const content
= typeof contentKey === 'string'
? t(contentKey)
: this._renderHTML(t(contentKey.key, contentKey.params));
return (
<Text style = { brandedDialog.text }>
{ content }
</Text>
);
}
_renderHTML: string => Object | string
}
export default translate(connect()(ConfirmDialog));

View File

@ -0,0 +1,22 @@
// @flow
import { connect } from 'react-redux';
import BaseDialog, { type Props } from './BaseDialog';
/**
* Implements a custom dialog component, where the content can freely be
* rendered.
*/
class CustomDialog extends BaseDialog<Props, *> {
/**
* Implements {@code BaseDialog._renderContent}.
*
* @inheritdoc
*/
_renderContent() {
return this.props.children;
}
}
export default connect()(CustomDialog);

View File

@ -0,0 +1,30 @@
// @flow
import { connect } from 'react-redux';
import { translate } from '../../../i18n';
import { type Props as BaseProps } from './BaseDialog';
import BaseSubmitDialog from './BaseSubmitDialog';
type Props = {
...BaseProps,
t: Function
}
/**
* Implements a submit dialog component that can have free content.
*/
class CustomSubmitDialog extends BaseSubmitDialog<Props, *> {
/**
* Implements {@code BaseSubmitDialog._renderSubmittable}.
*
* @inheritdoc
*/
_renderSubmittable() {
return this.props.children;
}
}
export default translate(connect()(CustomSubmitDialog));

View File

@ -6,15 +6,15 @@ import { Modal, StyleSheet, TextInput } from 'react-native';
import Prompt from 'react-native-prompt';
import { connect } from 'react-redux';
import { translate } from '../../i18n';
import { LoadingIndicator } from '../../react';
import { set } from '../../redux';
import { translate } from '../../../i18n';
import { LoadingIndicator } from '../../../react';
import { set } from '../../../redux';
import AbstractDialog from './AbstractDialog';
import AbstractDialog from '../AbstractDialog';
import type {
Props as AbstractDialogProps,
State as AbstractDialogState
} from './AbstractDialog';
} from '../AbstractDialog';
import { dialog as styles } from './styles';
/**
@ -44,6 +44,11 @@ type Props = {
*/
bodyKey: string,
/**
* Function to be used to retreive translated i18n labels.
*/
t: Function,
textInputProps: Object
};

View File

@ -0,0 +1,133 @@
// @flow
import React from 'react';
import { View, Text, TextInput, TouchableOpacity } from 'react-native';
import { connect } from 'react-redux';
import { translate } from '../../../i18n';
import { type State as AbstractState } from '../AbstractDialog';
import BaseDialog, { type Props as BaseProps } from './BaseDialog';
import {
FIELD_UNDERLINE,
brandedDialog,
inputDialog as styles
} from './styles';
type Props = {
...BaseProps,
/**
* The untranslated i18n key for the field label on the dialog.
*/
contentKey: string,
t: Function,
textInputProps: ?Object
}
type State = {
...AbstractState,
/**
* The current value of the field.
*/
fieldValue: ?string
};
/**
* Implements a single field input dialog component.
*/
class InputDialog extends BaseDialog<Props, State> {
/**
* Instantiates a new {@code InputDialog}.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this.state = {
fieldValue: undefined
};
this._onChangeText = this._onChangeText.bind(this);
this._onSubmitValue = this._onSubmitValue.bind(this);
}
/**
* Implements {@code BaseDialog._renderContent}.
*
* @inheritdoc
*/
_renderContent() {
const { okDisabled, t } = this.props;
return (
<View>
<View
style = { [
brandedDialog.mainWrapper,
styles.fieldWrapper
] }>
<Text style = { styles.fieldLabel }>
{ t(this.props.contentKey) }
</Text>
<TextInput
onChangeText = { this._onChangeText }
style = { styles.field }
underlineColorAndroid = { FIELD_UNDERLINE }
value = { this.state.fieldValue }
{ ...this.props.textInputProps } />
</View>
<View style = { brandedDialog.buttonWrapper }>
<TouchableOpacity
disabled = { okDisabled }
onPress = { this._onSubmit }
style = { [
brandedDialog.button,
brandedDialog.buttonFarLeft,
brandedDialog.buttonFarRight
] }>
<Text style = { brandedDialog.text }>
{ t('dialog.Ok') }
</Text>
</TouchableOpacity>
</View>
</View>
);
}
_onCancel: () => void;
_onChangeText: string => void;
/**
* Callback to be invoked when the text in the field changes.
*
* @param {string} fieldValue - The updated field value.
* @returns {void}
*/
_onChangeText(fieldValue) {
this.setState({
fieldValue
});
}
_onSubmit: ?string => boolean;
_onSubmitValue: () => boolean;
/**
* Callback to be invoked when the value of this dialog is submitted.
*
* @returns {boolean}
*/
_onSubmitValue() {
return this._onSubmit(this.state.fieldValue);
}
}
export default translate(connect()(InputDialog));

View File

@ -0,0 +1,12 @@
// @flow
export { default as BottomSheet } from './BottomSheet';
export { default as ConfirmDialog } from './ConfirmDialog';
export { default as CustomDialog } from './CustomDialog';
export { default as Dialog } from './Dialog';
export { default as InputDialog } from './InputDialog';
export { default as CustomSubmitDialog } from './CustomSubmitDialog';
// NOTE: Some dialogs reuse the style of these base classes for consistency
// and as we're in a /native namespace, it's safe to export the styles.
export * from './styles';

View File

@ -0,0 +1,183 @@
// @flow
import { StyleSheet } from 'react-native';
import { BoxModel, ColorPalette, createStyleSheet } from '../../../styles';
import { PREFERRED_DIALOG_SIZE } from '../../constants';
const BORDER_RADIUS = 5;
const DIALOG_BORDER_COLOR = 'rgba(255, 255, 255, 0.2)';
export const FIELD_UNDERLINE = ColorPalette.transparent;
export const PLACEHOLDER_COLOR = ColorPalette.lightGrey;
/**
* The React {@code Component} styles of {@code BottomSheet}. These have
* been implemented as per the Material Design guidelines:
* {@link https://material.io/guidelines/components/bottom-sheets.html}.
*/
export const bottomSheetStyles = createStyleSheet({
/**
* Style for a backdrop which dims the view in the background. This view
* will also be clickable. The backgroundColor is applied to the overlay
* view instead, so the modal animation doesn't affect the backdrop.
*/
backdrop: {
...StyleSheet.absoluteFillObject
},
/**
* Style for the container of the sheet.
*/
container: {
alignItems: 'flex-end',
flex: 1,
flexDirection: 'row',
justifyContent: 'center'
},
/**
* Style for an overlay on top of which the sheet will be displayed.
*/
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.8)'
},
/**
* Bottom sheet's base style.
*/
sheet: {
backgroundColor: ColorPalette.white,
flex: 1,
paddingHorizontal: 16,
paddingVertical: 8
}
});
export const brandedDialog = createStyleSheet({
/**
* The style of bold {@code Text} rendered by the {@code Dialog}s of the
* feature authentication.
*/
boldDialogText: {
fontWeight: 'bold'
},
button: {
backgroundColor: ColorPalette.blue,
flex: 1,
padding: BoxModel.padding * 1.5
},
buttonFarLeft: {
borderBottomLeftRadius: BORDER_RADIUS
},
buttonFarRight: {
borderBottomRightRadius: BORDER_RADIUS
},
buttonSeparator: {
borderRightColor: DIALOG_BORDER_COLOR,
borderRightWidth: 1
},
buttonWrapper: {
alignItems: 'stretch',
borderRadius: BORDER_RADIUS,
flexDirection: 'row'
},
closeStyle: {
color: ColorPalette.white,
fontSize: 16
},
closeWrapper: {
alignSelf: 'flex-end',
padding: BoxModel.padding
},
dialog: {
alignItems: 'stretch',
backgroundColor: 'rgb(0, 3, 6)',
borderColor: DIALOG_BORDER_COLOR,
borderRadius: BORDER_RADIUS,
borderWidth: 1,
flex: 1,
flexDirection: 'column',
maxWidth: PREFERRED_DIALOG_SIZE
},
mainWrapper: {
alignSelf: 'stretch',
padding: BoxModel.padding * 2,
// The added bottom padding is to compensate the empty space around the
// close icon.
paddingBottom: BoxModel.padding * 3
},
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
backgroundColor: 'rgba(127, 127, 127, 0.6)',
flexDirection: 'row',
justifyContent: 'center',
padding: 30
},
text: {
color: ColorPalette.white,
fontSize: 16,
textAlign: 'center'
}
});
/**
* The React {@code Component} styles of {@code Dialog}.
*/
export const dialog = createStyleSheet({
/**
* The style of the {@code Text} in a {@code Dialog} button.
*/
buttonText: {
color: ColorPalette.blue
},
/**
* The style of the {@code Text} in a {@code Dialog} button which is
* disabled.
*/
disabledButtonText: {
color: ColorPalette.darkGrey
}
});
export const inputDialog = createStyleSheet({
bottomField: {
marginBottom: 0
},
field: {
...brandedDialog.text,
borderBottomWidth: 1,
borderColor: DIALOG_BORDER_COLOR,
margin: BoxModel.margin,
textAlign: 'left'
},
fieldLabel: {
...brandedDialog.text,
margin: BoxModel.margin,
textAlign: 'left'
},
fieldWrapper: {
...brandedDialog.mainWrapper,
paddingBottom: BoxModel.padding * 2
}
});

View File

@ -1,75 +1,14 @@
import { StyleSheet } from 'react-native';
import { BoxModel, ColorPalette, createStyleSheet } from '../../styles';
import { BoxModel, createStyleSheet } from '../../styles';
/**
* The React {@code Component} styles of {@code Dialog}.
*/
export const dialog = createStyleSheet({
/**
* The style of the {@code Text} in a {@code Dialog} button.
*/
buttonText: {
color: ColorPalette.blue
},
export default createStyleSheet({
/**
* Unified container for a consistent Dialog style.
*/
dialogContainer: {
paddingHorizontal: BoxModel.padding,
paddingVertical: 1.5 * BoxModel.padding
},
/**
* The style of the {@code Text} in a {@code Dialog} button which is
* disabled.
*/
disabledButtonText: {
color: ColorPalette.darkGrey
}
});
/**
* The React {@code Component} styles of {@code BottomSheet}. These have
* been implemented as per the Material Design guidelines:
* {@link https://material.io/guidelines/components/bottom-sheets.html}.
*/
export const bottomSheetStyles = createStyleSheet({
/**
* Style for a backdrop which dims the view in the background. This view
* will also be clickable. The backgroundColor is applied to the overlay
* view instead, so the modal animation doesn't affect the backdrop.
*/
backdrop: {
...StyleSheet.absoluteFillObject
},
/**
* Style for the container of the sheet.
*/
container: {
alignItems: 'flex-end',
flex: 1,
flexDirection: 'row',
justifyContent: 'center'
},
/**
* Style for an overlay on top of which the sheet will be displayed.
*/
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.8)'
},
/**
* Bottom sheet's base style.
*/
sheet: {
flex: 1,
backgroundColor: ColorPalette.white,
paddingHorizontal: 16,
paddingVertical: 8
}
});

View File

@ -2,4 +2,4 @@
* Placeholder styles for web to be able to use cross platform components
* unmodified such as {@code DialogContent}.
*/
export const dialog = {};
export default {};

View File

@ -3,8 +3,8 @@
import React from 'react';
import { connect } from 'react-redux';
import AbstractDialog from './AbstractDialog';
import type { Props as AbstractDialogProps, State } from './AbstractDialog';
import AbstractDialog from '../AbstractDialog';
import type { Props as AbstractDialogProps, State } from '../AbstractDialog';
import StatelessDialog from './StatelessDialog';
/**

View File

@ -3,8 +3,8 @@
import Tabs from '@atlaskit/tabs';
import React, { Component } from 'react';
import { StatelessDialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import { StatelessDialog } from '../../../dialog';
import { translate } from '../../../i18n';
const logger = require('jitsi-meet-logger').getLogger(__filename);

View File

@ -7,9 +7,9 @@ import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { translate } from '../../i18n';
import { translate } from '../../../i18n';
import type { DialogProps } from '../constants';
import type { DialogProps } from '../../constants';
/**
* The ID to be used for the cancel button if enabled.
@ -56,6 +56,11 @@ type Props = {
*/
submitDisabled: boolean,
/**
* Function to be used to retreive translated i18n labels.
*/
t: Function,
/**
* Width of the dialog, can be:
* - 'small' (400px), 'medium' (600px), 'large' (800px),

View File

@ -0,0 +1,7 @@
// @flow
export { default as AbstractDialogTab } from './AbstractDialogTab';
export type { Props as AbstractDialogTabProps } from './AbstractDialogTab';
export { default as Dialog } from './Dialog';
export { default as DialogWithTabs } from './DialogWithTabs';
export { default as StatelessDialog } from './StatelessDialog';

View File

@ -38,9 +38,11 @@ export type DialogProps = {
onSubmit: Function,
/**
* Used to obtain translations in children classes.
* Additional style to be applied on the dialog.
*
* NOTE: Not all dialog types support this!
*/
t: Function,
style?: Object,
/**
* Key to use for showing a title.
@ -54,3 +56,13 @@ export type DialogProps = {
*/
titleString: string
};
/**
* A preferred (or optimal) dialog size. This constant is reused in many
* components, where dialog size optimization is suggested.
*
* NOTE: Even though we support valious devices, including tablets, we don't
* want the dialogs to be oversized even on larger devices. This number seems
* to be a good compromise, but also easy to update.
*/
export const PREFERRED_DIALOG_SIZE = 300;

View File

@ -12,10 +12,7 @@ import {
} from '../../../modules/transport';
import { parseURLParams } from '../base/config';
import { DeviceSelection } from '../device-selection';
// Using the full path to the file to prevent adding unnecessary code into the
// dialog popup bundle.
import DialogWithTabs from '../base/dialog/components/DialogWithTabs';
import { DialogWithTabs } from '../base/dialog';
const logger = Logger.getLogger(__filename);