This commit is contained in:
241
react/features/base/ui/Tokens.ts
Normal file
241
react/features/base/ui/Tokens.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// Mapping between the token used and the color
|
||||
export const colorMap = {
|
||||
// ----- Surfaces -----
|
||||
|
||||
// Default page background. If this changes, make sure to adapt the native side as well:
|
||||
// - JitsiMeetView.m
|
||||
// - JitsiMeetView.java
|
||||
uiBackground: 'surface01',
|
||||
|
||||
// Container backgrounds
|
||||
ui01: 'surface02',
|
||||
ui02: 'surface03',
|
||||
ui03: 'ui02',
|
||||
ui04: 'surface05',
|
||||
ui05: 'ui01',
|
||||
ui06: 'ui03',
|
||||
ui07: 'surface08',
|
||||
ui08: 'ui21',
|
||||
ui09: 'ui08',
|
||||
ui10: 'ui04',
|
||||
|
||||
// ----- Actions -----
|
||||
|
||||
// Primary
|
||||
action01: 'action01',
|
||||
action01Hover: 'hover01',
|
||||
action01Active: 'active01',
|
||||
|
||||
// Secondary
|
||||
action02: 'action02',
|
||||
action02Hover: 'hover02',
|
||||
action02Active: 'active02',
|
||||
|
||||
// Destructive
|
||||
actionDanger: 'action03',
|
||||
actionDangerHover: 'hover03',
|
||||
actionDangerActive: 'active03',
|
||||
|
||||
// Tertiary
|
||||
action03: 'transparent',
|
||||
action03Hover: 'hover05',
|
||||
action03Active: 'surface03',
|
||||
|
||||
// Disabled
|
||||
disabled01: 'disabled01',
|
||||
|
||||
// Focus
|
||||
focus01: 'focus01',
|
||||
|
||||
// ----- Links -----
|
||||
|
||||
link01: 'action01',
|
||||
link01Hover: 'hover07',
|
||||
link01Active: 'action04',
|
||||
|
||||
// ----- Text -----
|
||||
|
||||
// Primary
|
||||
text01: 'textColor01',
|
||||
|
||||
// Secondary
|
||||
text02: 'textColor02',
|
||||
|
||||
// Tertiary
|
||||
text03: 'ui03',
|
||||
|
||||
// High-contrast
|
||||
text04: 'surface01',
|
||||
|
||||
// Error
|
||||
textError: 'alertRed',
|
||||
|
||||
// ----- Icons -----
|
||||
|
||||
// Primary
|
||||
icon01: 'icon01',
|
||||
|
||||
// Secondary
|
||||
icon02: 'ui21',
|
||||
|
||||
// Tertiary
|
||||
icon03: 'icon07',
|
||||
|
||||
// High-contrast
|
||||
icon04: 'surface01',
|
||||
|
||||
// Error
|
||||
iconError: 'action03',
|
||||
|
||||
// Normal
|
||||
iconNormal: 'action04',
|
||||
|
||||
// Success
|
||||
iconSuccess: 'alertGreen',
|
||||
|
||||
// Warning
|
||||
iconWarning: 'warning01',
|
||||
|
||||
// ----- Forms -----
|
||||
|
||||
field01: 'ui02',
|
||||
|
||||
// ----- Feedback -----
|
||||
|
||||
// Success
|
||||
success01: 'success05',
|
||||
success02: 'success01',
|
||||
|
||||
// Warning
|
||||
warning01: 'warning01',
|
||||
warning02: 'warning06',
|
||||
|
||||
// ----- Support -----
|
||||
|
||||
support05: 'support05',
|
||||
support06: 'support06'
|
||||
};
|
||||
|
||||
|
||||
export const font = {
|
||||
weightRegular: 400,
|
||||
weightSemiBold: 600
|
||||
};
|
||||
|
||||
export const shape = {
|
||||
borderRadius: 6,
|
||||
circleRadius: 50,
|
||||
boxShadow: 'inset 0px -1px 0px rgba(255, 255, 255, 0.15)'
|
||||
};
|
||||
|
||||
export const spacing = [ 0, 4, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128 ];
|
||||
|
||||
export const typography = {
|
||||
labelRegular: 'label01',
|
||||
|
||||
labelBold: 'labelBold01',
|
||||
|
||||
bodyShortRegularSmall: {
|
||||
fontSize: '0.625rem',
|
||||
lineHeight: '1rem',
|
||||
fontWeight: font.weightRegular,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
bodyShortRegular: {
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.25rem',
|
||||
fontWeight: font.weightRegular,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
bodyShortBold: {
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.25rem',
|
||||
fontWeight: font.weightSemiBold,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
bodyShortRegularLarge: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.375rem',
|
||||
fontWeight: font.weightRegular,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
bodyShortBoldLarge: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.375rem',
|
||||
fontWeight: font.weightSemiBold,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
bodyLongRegular: {
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.5rem',
|
||||
fontWeight: font.weightRegular,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
bodyLongRegularLarge: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.625rem',
|
||||
fontWeight: font.weightRegular,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
bodyLongBold: {
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.5rem',
|
||||
fontWeight: font.weightSemiBold,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
bodyLongBoldLarge: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.625rem',
|
||||
fontWeight: font.weightSemiBold,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
heading1: 'heading01',
|
||||
|
||||
heading2: 'heading02',
|
||||
|
||||
heading3: {
|
||||
fontSize: '2rem',
|
||||
lineHeight: '2.5rem',
|
||||
fontWeight: font.weightSemiBold,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
heading4: {
|
||||
fontSize: '1.75rem',
|
||||
lineHeight: '2.25rem',
|
||||
fontWeight: font.weightSemiBold,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
heading5: {
|
||||
fontSize: '1.25rem',
|
||||
lineHeight: '1.75rem',
|
||||
fontWeight: font.weightSemiBold,
|
||||
letterSpacing: 0
|
||||
},
|
||||
|
||||
heading6: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.625rem',
|
||||
fontWeight: font.weightSemiBold,
|
||||
letterSpacing: 0
|
||||
}
|
||||
};
|
||||
|
||||
export const breakpoints = {
|
||||
values: {
|
||||
'0': 0,
|
||||
'320': 320,
|
||||
'400': 400,
|
||||
'480': 480
|
||||
}
|
||||
};
|
||||
12
react/features/base/ui/components/BaseTheme.native.ts
Normal file
12
react/features/base/ui/components/BaseTheme.native.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { colorMap, font, shape, spacing, typography } from '../Tokens';
|
||||
import { createNativeTheme } from '../functions.native';
|
||||
|
||||
import updateTheme from './updateTheme.native';
|
||||
|
||||
export default createNativeTheme(updateTheme({
|
||||
font,
|
||||
colorMap,
|
||||
spacing,
|
||||
shape,
|
||||
typography
|
||||
}));
|
||||
11
react/features/base/ui/components/BaseTheme.web.ts
Normal file
11
react/features/base/ui/components/BaseTheme.web.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { breakpoints, colorMap, font, shape, spacing, typography } from '../Tokens';
|
||||
import { createWebTheme } from '../functions.web';
|
||||
|
||||
export default createWebTheme({
|
||||
font,
|
||||
colorMap,
|
||||
spacing,
|
||||
shape,
|
||||
typography,
|
||||
breakpoints
|
||||
});
|
||||
23
react/features/base/ui/components/GlobalStyles.web.tsx
Normal file
23
react/features/base/ui/components/GlobalStyles.web.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { GlobalStyles as MUIGlobalStyles } from 'tss-react';
|
||||
import { useStyles } from 'tss-react/mui';
|
||||
|
||||
import { commonStyles } from '../constants';
|
||||
|
||||
/**
|
||||
* A component generating all the global styles.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function GlobalStyles() {
|
||||
const { theme } = useStyles();
|
||||
|
||||
return (
|
||||
<MUIGlobalStyles
|
||||
styles = {
|
||||
commonStyles(theme)
|
||||
} />
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalStyles;
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { Provider as PaperProvider } from 'react-native-paper';
|
||||
|
||||
import BaseTheme from './BaseTheme.native';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The children of the component.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme provider for the mobile app.
|
||||
*
|
||||
* @param {Object} props - The props of the component.
|
||||
* @returns {React.ReactNode}
|
||||
*/
|
||||
export default function JitsiThemePaperProvider(props: IProps) {
|
||||
return <PaperProvider theme = { BaseTheme }>{ props.children }</PaperProvider>;
|
||||
}
|
||||
50
react/features/base/ui/components/JitsiThemeProvider.web.tsx
Normal file
50
react/features/base/ui/components/JitsiThemeProvider.web.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles';
|
||||
import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
|
||||
import BaseTheme from './BaseTheme.web';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The default theme or theme set through advanced branding.
|
||||
*/
|
||||
_theme: Object;
|
||||
|
||||
/**
|
||||
* The children of the component.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme provider for the web app.
|
||||
*
|
||||
* @param {Object} props - The props of the component.
|
||||
* @returns {React.ReactNode}
|
||||
*/
|
||||
function JitsiThemeProvider(props: IProps) {
|
||||
return (
|
||||
<StyledEngineProvider injectFirst = { true }>
|
||||
<ThemeProvider theme = { props._theme }>{ props.children }</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
const { muiBrandedTheme } = state['features/dynamic-branding'];
|
||||
|
||||
return {
|
||||
_theme: muiBrandedTheme || BaseTheme
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(_mapStateToProps)(JitsiThemeProvider);
|
||||
120
react/features/base/ui/components/native/Button.tsx
Normal file
120
react/features/base/ui/components/native/Button.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleProp, TouchableHighlight } from 'react-native';
|
||||
import { Button as NativePaperButton, Text } from 'react-native-paper';
|
||||
import { IconSource } from 'react-native-paper/lib/typescript/components/Icon';
|
||||
|
||||
import { BUTTON_MODES, BUTTON_TYPES } from '../../constants.native';
|
||||
import BaseTheme from '../BaseTheme.native';
|
||||
import { IButtonProps } from '../types';
|
||||
|
||||
import styles from './buttonStyles';
|
||||
|
||||
|
||||
export interface IProps extends IButtonProps {
|
||||
color?: string | undefined;
|
||||
contentStyle?: Object | undefined;
|
||||
id?: string;
|
||||
labelStyle?: Object | undefined;
|
||||
mode?: any;
|
||||
style?: Object | undefined;
|
||||
}
|
||||
|
||||
const Button: React.FC<IProps> = ({
|
||||
accessibilityLabel,
|
||||
color: buttonColor,
|
||||
contentStyle,
|
||||
disabled,
|
||||
icon,
|
||||
id,
|
||||
labelKey,
|
||||
labelStyle,
|
||||
mode = BUTTON_MODES.CONTAINED,
|
||||
onClick: onPress,
|
||||
style,
|
||||
type
|
||||
}: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { DESTRUCTIVE, PRIMARY, SECONDARY, TERTIARY } = BUTTON_TYPES;
|
||||
const { CONTAINED, TEXT } = BUTTON_MODES;
|
||||
|
||||
let buttonLabelStyles;
|
||||
let buttonStyles;
|
||||
let color;
|
||||
|
||||
if (type === PRIMARY) {
|
||||
buttonLabelStyles = mode === TEXT
|
||||
? styles.buttonLabelPrimaryText
|
||||
: styles.buttonLabelPrimary;
|
||||
color = mode === CONTAINED && BaseTheme.palette.action01;
|
||||
} else if (type === SECONDARY) {
|
||||
buttonLabelStyles = styles.buttonLabelSecondary;
|
||||
color = mode === CONTAINED && BaseTheme.palette.action02;
|
||||
} else if (type === DESTRUCTIVE) {
|
||||
buttonLabelStyles = mode === TEXT
|
||||
? styles.buttonLabelDestructiveText
|
||||
: styles.buttonLabelDestructive;
|
||||
color = mode === CONTAINED && BaseTheme.palette.actionDanger;
|
||||
} else {
|
||||
color = buttonColor;
|
||||
buttonLabelStyles = styles.buttonLabel;
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
buttonLabelStyles = styles.buttonLabelDisabled;
|
||||
buttonStyles = styles.buttonDisabled;
|
||||
} else {
|
||||
buttonStyles = styles.button;
|
||||
}
|
||||
|
||||
if (type === TERTIARY) {
|
||||
if (disabled) {
|
||||
buttonLabelStyles = styles.buttonLabelTertiaryDisabled;
|
||||
}
|
||||
buttonLabelStyles = styles.buttonLabelTertiary;
|
||||
|
||||
return (
|
||||
<TouchableHighlight
|
||||
accessibilityLabel = { accessibilityLabel }
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
onPress = { onPress }
|
||||
style = { [
|
||||
buttonStyles,
|
||||
style
|
||||
] as StyleProp<object> }>
|
||||
<Text
|
||||
style = { [
|
||||
buttonLabelStyles,
|
||||
labelStyle
|
||||
] as StyleProp<object> }>{ t(labelKey ?? '') }</Text>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NativePaperButton
|
||||
accessibilityLabel = { t(accessibilityLabel ?? '') }
|
||||
buttonColor = { color }
|
||||
children = { t(labelKey ?? '') }
|
||||
contentStyle = { [
|
||||
styles.buttonContent,
|
||||
contentStyle
|
||||
] as StyleProp<object> }
|
||||
disabled = { disabled }
|
||||
icon = { icon as IconSource | undefined }
|
||||
id = { id }
|
||||
labelStyle = { [
|
||||
buttonLabelStyles,
|
||||
labelStyle
|
||||
] as StyleProp<object> }
|
||||
mode = { mode }
|
||||
onPress = { onPress }
|
||||
style = { [
|
||||
buttonStyles,
|
||||
style
|
||||
] as StyleProp<object> } />
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
71
react/features/base/ui/components/native/IconButton.tsx
Normal file
71
react/features/base/ui/components/native/IconButton.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { TouchableHighlight, ViewStyle } from 'react-native';
|
||||
|
||||
import Icon from '../../../icons/components/Icon';
|
||||
import styles from '../../../react/components/native/styles';
|
||||
import { IIconButtonProps } from '../../../react/types';
|
||||
import { BUTTON_TYPES } from '../../constants.native';
|
||||
import BaseTheme from '../BaseTheme.native';
|
||||
|
||||
|
||||
const IconButton: React.FC<IIconButtonProps> = ({
|
||||
accessibilityLabel,
|
||||
color: iconColor,
|
||||
disabled,
|
||||
id,
|
||||
onPress,
|
||||
size,
|
||||
src,
|
||||
style,
|
||||
tapColor,
|
||||
type
|
||||
}: IIconButtonProps) => {
|
||||
const { PRIMARY, SECONDARY, TERTIARY } = BUTTON_TYPES;
|
||||
|
||||
let color;
|
||||
let underlayColor;
|
||||
let iconButtonContainerStyles;
|
||||
|
||||
if (type === PRIMARY) {
|
||||
color = BaseTheme.palette.icon01;
|
||||
iconButtonContainerStyles = styles.iconButtonContainerPrimary;
|
||||
underlayColor = BaseTheme.palette.action01;
|
||||
} else if (type === SECONDARY) {
|
||||
color = BaseTheme.palette.icon04;
|
||||
iconButtonContainerStyles = styles.iconButtonContainerSecondary;
|
||||
underlayColor = BaseTheme.palette.action02;
|
||||
} else if (type === TERTIARY) {
|
||||
color = iconColor;
|
||||
iconButtonContainerStyles = styles.iconButtonContainer;
|
||||
underlayColor = BaseTheme.palette.action03;
|
||||
} else {
|
||||
color = iconColor;
|
||||
underlayColor = tapColor;
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
color = BaseTheme.palette.icon03;
|
||||
iconButtonContainerStyles = styles.iconButtonContainerDisabled;
|
||||
underlayColor = 'transparent';
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableHighlight
|
||||
accessibilityLabel = { accessibilityLabel }
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
onPress = { onPress }
|
||||
style = { [
|
||||
iconButtonContainerStyles,
|
||||
style
|
||||
] as ViewStyle[] }
|
||||
underlayColor = { underlayColor }>
|
||||
<Icon
|
||||
color = { color }
|
||||
size = { size ?? 20 }
|
||||
src = { src } />
|
||||
</TouchableHighlight>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconButton;
|
||||
197
react/features/base/ui/components/native/Input.tsx
Normal file
197
react/features/base/ui/components/native/Input.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { forwardRef, useCallback, useState } from 'react';
|
||||
import {
|
||||
KeyboardTypeOptions,
|
||||
NativeSyntheticEvent,
|
||||
ReturnKeyTypeOptions,
|
||||
StyleProp,
|
||||
Text,
|
||||
TextInput,
|
||||
TextInputChangeEventData,
|
||||
TextInputFocusEventData,
|
||||
TextInputKeyPressEventData,
|
||||
TextInputSubmitEditingEventData,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
|
||||
import Icon from '../../../icons/components/Icon';
|
||||
import { IconCloseCircle } from '../../../icons/svg';
|
||||
import BaseTheme from '../../../ui/components/BaseTheme.native';
|
||||
import { IInputProps } from '../types';
|
||||
|
||||
import styles from './inputStyles';
|
||||
|
||||
interface IProps extends IInputProps {
|
||||
accessibilityLabel?: any;
|
||||
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters' | undefined;
|
||||
autoFocus?: boolean;
|
||||
blurOnSubmit?: boolean | undefined;
|
||||
bottomLabel?: string;
|
||||
customStyles?: ICustomStyles;
|
||||
editable?: boolean | undefined;
|
||||
|
||||
/**
|
||||
* The id to set on the input element.
|
||||
* This is required because we need it internally to tie the input to its
|
||||
* info (label, error) so that screen reader users don't get lost.
|
||||
*/
|
||||
id?: string;
|
||||
keyboardType?: KeyboardTypeOptions;
|
||||
maxLength?: number | undefined;
|
||||
minHeight?: number | string | undefined;
|
||||
multiline?: boolean | undefined;
|
||||
numberOfLines?: number | undefined;
|
||||
onBlur?: ((e: NativeSyntheticEvent<TextInputFocusEventData>) => void) | undefined;
|
||||
onFocus?: ((e: NativeSyntheticEvent<TextInputFocusEventData>) => void) | undefined;
|
||||
onKeyPress?: ((e: NativeSyntheticEvent<TextInputKeyPressEventData>) => void) | undefined;
|
||||
onSubmitEditing?: (value: string) => void;
|
||||
pointerEvents?: 'box-none' | 'none' | 'box-only' | 'auto' | undefined;
|
||||
returnKeyType?: ReturnKeyTypeOptions | undefined;
|
||||
secureTextEntry?: boolean | undefined;
|
||||
textContentType?: any;
|
||||
}
|
||||
|
||||
interface ICustomStyles {
|
||||
container?: Object;
|
||||
input?: Object;
|
||||
}
|
||||
|
||||
const Input = forwardRef<TextInput, IProps>(({
|
||||
accessibilityLabel,
|
||||
autoCapitalize,
|
||||
autoFocus,
|
||||
blurOnSubmit,
|
||||
bottomLabel,
|
||||
clearable,
|
||||
customStyles,
|
||||
disabled,
|
||||
error,
|
||||
icon,
|
||||
id,
|
||||
keyboardType,
|
||||
label,
|
||||
maxLength,
|
||||
minHeight,
|
||||
multiline,
|
||||
numberOfLines,
|
||||
onBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
onKeyPress,
|
||||
onSubmitEditing,
|
||||
placeholder,
|
||||
pointerEvents,
|
||||
returnKeyType,
|
||||
secureTextEntry,
|
||||
textContentType,
|
||||
value
|
||||
}: IProps, ref) => {
|
||||
const [ focused, setFocused ] = useState(false);
|
||||
const handleChange = useCallback((e: NativeSyntheticEvent<TextInputChangeEventData>) => {
|
||||
const { nativeEvent: { text } } = e;
|
||||
|
||||
onChange?.(text);
|
||||
}, [ onChange ]);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
onChange?.('');
|
||||
}, [ onChange ]);
|
||||
|
||||
const handleBlur = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => {
|
||||
setFocused(false);
|
||||
onBlur?.(e);
|
||||
}, [ onBlur ]);
|
||||
|
||||
const handleFocus = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => {
|
||||
setFocused(true);
|
||||
onFocus?.(e);
|
||||
}, [ onFocus ]);
|
||||
|
||||
const handleKeyPress = useCallback((e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
onKeyPress?.(e);
|
||||
}, [ onKeyPress ]);
|
||||
|
||||
const handleSubmitEditing = useCallback((e: NativeSyntheticEvent<TextInputSubmitEditingEventData>) => {
|
||||
const { nativeEvent: { text } } = e;
|
||||
|
||||
onSubmitEditing?.(text);
|
||||
}, [ onSubmitEditing ]);
|
||||
|
||||
return (<View style = { [ styles.inputContainer, customStyles?.container ] as StyleProp<ViewStyle> }>
|
||||
{label && <Text style = { styles.label }>{ label }</Text>}
|
||||
<View style = { styles.fieldContainer as StyleProp<ViewStyle> }>
|
||||
{icon && <Icon
|
||||
size = { 22 }
|
||||
src = { icon }
|
||||
style = { styles.icon } />}
|
||||
<TextInput
|
||||
accessibilityLabel = { accessibilityLabel }
|
||||
autoCapitalize = { autoCapitalize }
|
||||
autoComplete = { 'off' }
|
||||
autoCorrect = { false }
|
||||
autoFocus = { autoFocus }
|
||||
blurOnSubmit = { blurOnSubmit }
|
||||
editable = { !disabled }
|
||||
id = { id }
|
||||
keyboardType = { keyboardType }
|
||||
maxLength = { maxLength }
|
||||
|
||||
// @ts-ignore
|
||||
minHeight = { minHeight }
|
||||
multiline = { multiline }
|
||||
numberOfLines = { numberOfLines }
|
||||
onBlur = { handleBlur }
|
||||
onChange = { handleChange }
|
||||
onFocus = { handleFocus }
|
||||
onKeyPress = { handleKeyPress }
|
||||
onSubmitEditing = { handleSubmitEditing }
|
||||
placeholder = { placeholder }
|
||||
placeholderTextColor = { BaseTheme.palette.text02 }
|
||||
pointerEvents = { pointerEvents }
|
||||
ref = { ref }
|
||||
returnKeyType = { returnKeyType }
|
||||
secureTextEntry = { secureTextEntry }
|
||||
spellCheck = { false }
|
||||
style = { [
|
||||
styles.input,
|
||||
clearable && styles.clearableInput,
|
||||
customStyles?.input,
|
||||
disabled && styles.inputDisabled,
|
||||
icon && styles.iconInput,
|
||||
multiline && styles.inputMultiline,
|
||||
focused && styles.inputFocused,
|
||||
error && styles.inputError
|
||||
] as StyleProp<TextStyle> }
|
||||
textContentType = { textContentType }
|
||||
value = { typeof value === 'number' ? `${value}` : value } />
|
||||
{ clearable && !disabled && value !== '' && (
|
||||
<TouchableOpacity
|
||||
onPress = { clearInput }
|
||||
style = { styles.clearButton as StyleProp<ViewStyle> }>
|
||||
<Icon
|
||||
size = { 22 }
|
||||
src = { IconCloseCircle }
|
||||
style = { styles.clearIcon } />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{
|
||||
bottomLabel && (
|
||||
<View>
|
||||
<Text
|
||||
id = { `${id}-description` }
|
||||
style = { [
|
||||
styles.bottomLabel,
|
||||
error && styles.bottomLabelError
|
||||
] }>
|
||||
{ bottomLabel }
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
</View>);
|
||||
});
|
||||
|
||||
export default Input;
|
||||
60
react/features/base/ui/components/native/Switch.tsx
Normal file
60
react/features/base/ui/components/native/Switch.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { ColorValue, StyleProp } from 'react-native';
|
||||
import { Switch as NativeSwitch } from 'react-native-paper';
|
||||
|
||||
import { ISwitchProps } from '../types';
|
||||
|
||||
import {
|
||||
DISABLED_TRACK_COLOR,
|
||||
ENABLED_TRACK_COLOR,
|
||||
THUMB_COLOR
|
||||
} from './switchStyles';
|
||||
|
||||
interface IProps extends ISwitchProps {
|
||||
|
||||
/**
|
||||
* Id for the switch.
|
||||
*/
|
||||
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Custom styles for the switch.
|
||||
*/
|
||||
style?: Object;
|
||||
|
||||
/**
|
||||
* Color of the switch button.
|
||||
*/
|
||||
thumbColor?: ColorValue;
|
||||
|
||||
/**
|
||||
* Color of the switch background.
|
||||
*/
|
||||
trackColor?: Object;
|
||||
}
|
||||
|
||||
const Switch = ({
|
||||
checked,
|
||||
disabled,
|
||||
id,
|
||||
onChange,
|
||||
thumbColor = THUMB_COLOR,
|
||||
trackColor = {
|
||||
true: ENABLED_TRACK_COLOR,
|
||||
false: DISABLED_TRACK_COLOR
|
||||
},
|
||||
style
|
||||
}: IProps) => (
|
||||
<NativeSwitch
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
ios_backgroundColor = { DISABLED_TRACK_COLOR }
|
||||
onValueChange = { onChange }
|
||||
style = { style as StyleProp<object> }
|
||||
thumbColor = { thumbColor }
|
||||
trackColor = { trackColor }
|
||||
value = { checked } />
|
||||
);
|
||||
|
||||
export default Switch;
|
||||
76
react/features/base/ui/components/native/buttonStyles.ts
Normal file
76
react/features/base/ui/components/native/buttonStyles.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import BaseTheme from '../../../ui/components/BaseTheme.native';
|
||||
|
||||
const BUTTON_HEIGHT = BaseTheme.spacing[7];
|
||||
|
||||
const button = {
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
display: 'flex',
|
||||
height: BUTTON_HEIGHT,
|
||||
justifyContent: 'center'
|
||||
};
|
||||
|
||||
const buttonLabel = {
|
||||
...BaseTheme.typography.bodyShortBold
|
||||
};
|
||||
|
||||
export default {
|
||||
button: {
|
||||
...button
|
||||
},
|
||||
|
||||
buttonLabel: {
|
||||
...buttonLabel
|
||||
},
|
||||
|
||||
buttonLabelDisabled: {
|
||||
...buttonLabel,
|
||||
color: BaseTheme.palette.text03
|
||||
},
|
||||
|
||||
buttonContent: {
|
||||
height: BUTTON_HEIGHT
|
||||
},
|
||||
|
||||
buttonDisabled: {
|
||||
...button,
|
||||
backgroundColor: BaseTheme.palette.ui08
|
||||
},
|
||||
|
||||
buttonLabelPrimary: {
|
||||
...buttonLabel,
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
|
||||
buttonLabelPrimaryText: {
|
||||
...buttonLabel,
|
||||
color: BaseTheme.palette.action01
|
||||
},
|
||||
|
||||
buttonLabelSecondary: {
|
||||
...buttonLabel,
|
||||
color: BaseTheme.palette.text04
|
||||
},
|
||||
|
||||
buttonLabelDestructive: {
|
||||
...buttonLabel,
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
|
||||
buttonLabelDestructiveText: {
|
||||
...buttonLabel,
|
||||
color: BaseTheme.palette.actionDanger
|
||||
},
|
||||
|
||||
buttonLabelTertiary: {
|
||||
...buttonLabel,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginHorizontal: BaseTheme.spacing[2],
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
buttonLabelTertiaryDisabled: {
|
||||
...buttonLabel,
|
||||
color: BaseTheme.palette.text03,
|
||||
textAlign: 'center'
|
||||
}
|
||||
};
|
||||
87
react/features/base/ui/components/native/inputStyles.ts
Normal file
87
react/features/base/ui/components/native/inputStyles.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import BaseTheme from '../../../ui/components/BaseTheme.native';
|
||||
|
||||
export default {
|
||||
inputContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
|
||||
label: {
|
||||
...BaseTheme.typography.bodyShortRegularLarge,
|
||||
lineHeight: 0,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginBottom: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
fieldContainer: {
|
||||
position: 'relative'
|
||||
},
|
||||
|
||||
icon: {
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
top: 14,
|
||||
left: 14
|
||||
},
|
||||
|
||||
input: {
|
||||
...BaseTheme.typography.bodyShortRegularLarge,
|
||||
backgroundColor: BaseTheme.palette.ui03,
|
||||
borderColor: BaseTheme.palette.ui03,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
borderWidth: 2,
|
||||
color: BaseTheme.palette.text01,
|
||||
paddingHorizontal: BaseTheme.spacing[3],
|
||||
height: BaseTheme.spacing[7],
|
||||
lineHeight: 20
|
||||
},
|
||||
|
||||
inputDisabled: {
|
||||
color: BaseTheme.palette.text03
|
||||
},
|
||||
|
||||
inputFocused: {
|
||||
borderColor: BaseTheme.palette.focus01
|
||||
},
|
||||
|
||||
inputError: {
|
||||
borderColor: BaseTheme.palette.textError
|
||||
},
|
||||
|
||||
iconInput: {
|
||||
paddingLeft: BaseTheme.spacing[6]
|
||||
},
|
||||
|
||||
inputMultiline: {
|
||||
height: BaseTheme.spacing[10],
|
||||
paddingTop: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
clearableInput: {
|
||||
paddingRight: BaseTheme.spacing[6]
|
||||
},
|
||||
|
||||
clearButton: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 14,
|
||||
width: BaseTheme.spacing[6],
|
||||
height: BaseTheme.spacing[7]
|
||||
},
|
||||
|
||||
clearIcon: {
|
||||
color: BaseTheme.palette.icon01
|
||||
},
|
||||
|
||||
bottomLabel: {
|
||||
...BaseTheme.typography.labelRegular,
|
||||
color: BaseTheme.palette.text02,
|
||||
marginTop: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
bottomLabelError: {
|
||||
color: BaseTheme.palette.textError
|
||||
}
|
||||
};
|
||||
5
react/features/base/ui/components/native/switchStyles.ts
Normal file
5
react/features/base/ui/components/native/switchStyles.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import BaseTheme from '../BaseTheme.native';
|
||||
|
||||
export const ENABLED_TRACK_COLOR = BaseTheme.palette.action01;
|
||||
export const DISABLED_TRACK_COLOR = BaseTheme.palette.ui05;
|
||||
export const THUMB_COLOR = BaseTheme.palette.icon01;
|
||||
113
react/features/base/ui/components/types.ts
Normal file
113
react/features/base/ui/components/types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { BUTTON_TYPES } from '../constants.any';
|
||||
|
||||
export interface IButtonProps {
|
||||
|
||||
/**
|
||||
* Label used for accessibility.
|
||||
*/
|
||||
accessibilityLabel?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the button should automatically focus.
|
||||
*/
|
||||
autoFocus?: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the button is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* The icon to be displayed on the button.
|
||||
*/
|
||||
icon?: Function;
|
||||
|
||||
/**
|
||||
* The translation key of the text to be displayed on the button.
|
||||
*/
|
||||
labelKey?: string;
|
||||
|
||||
/**
|
||||
* Click callback.
|
||||
*/
|
||||
onClick?: (e?: any) => void;
|
||||
|
||||
/**
|
||||
* Key press callback.
|
||||
*/
|
||||
onKeyPress?: (e?: React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
|
||||
/**
|
||||
* The type of button to be displayed.
|
||||
*/
|
||||
type?: BUTTON_TYPES;
|
||||
}
|
||||
|
||||
export interface IInputProps {
|
||||
|
||||
/**
|
||||
* Whether the input is be clearable. (show clear button).
|
||||
*/
|
||||
clearable?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the input is be disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the input is in error state.
|
||||
*/
|
||||
error?: boolean;
|
||||
|
||||
/**
|
||||
* The icon to be displayed on the input.
|
||||
*/
|
||||
icon?: Function;
|
||||
|
||||
/**
|
||||
* The label of the input.
|
||||
*/
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* Change callback.
|
||||
*/
|
||||
onChange?: (value: string) => void;
|
||||
|
||||
/**
|
||||
* The input placeholder text.
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* The value of the input.
|
||||
*/
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export interface ISwitchProps {
|
||||
|
||||
/**
|
||||
* Whether or not the toggle is on.
|
||||
*/
|
||||
checked: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the toggle is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Toggle change callback.
|
||||
*/
|
||||
onChange: (on?: boolean) => void;
|
||||
}
|
||||
|
||||
export type MultiSelectItem = {
|
||||
content: string;
|
||||
description?: string;
|
||||
elemBefore?: Element;
|
||||
isDisabled?: boolean;
|
||||
value: string;
|
||||
};
|
||||
9
react/features/base/ui/components/updateTheme.native.ts
Normal file
9
react/features/base/ui/components/updateTheme.native.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Custom theme for setting client branding.
|
||||
*
|
||||
* @param {Object} theme - The ui tokens theme object.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export default function updateTheme(theme: Object) {
|
||||
return theme;
|
||||
}
|
||||
6
react/features/base/ui/components/variables.ts
Normal file
6
react/features/base/ui/components/variables.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Prejoin / premeeting screen.
|
||||
*/
|
||||
|
||||
// Maps SCSS variable $prejoinDefaultContentWidth
|
||||
export const PREJOIN_DEFAULT_CONTENT_WIDTH = '336px';
|
||||
217
react/features/base/ui/components/web/BaseDialog.tsx
Normal file
217
react/features/base/ui/components/web/BaseDialog.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { ReactNode, useCallback, useContext, useEffect } from 'react';
|
||||
import { FocusOn } from 'react-focus-on';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { keyframes } from 'tss-react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { isElementInTheViewport } from '../../functions.web';
|
||||
|
||||
import { DialogTransitionContext } from './DialogTransition';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'fixed',
|
||||
color: theme.palette.text01,
|
||||
...theme.typography.bodyLongRegular,
|
||||
top: 0,
|
||||
left: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
zIndex: 301,
|
||||
animation: `${keyframes`
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
`} 0.2s forwards ease-out`,
|
||||
|
||||
'&.unmount': {
|
||||
animation: `${keyframes`
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`} 0.15s forwards ease-in`
|
||||
}
|
||||
},
|
||||
|
||||
backdrop: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: theme.palette.ui02,
|
||||
opacity: 0.75
|
||||
},
|
||||
|
||||
modal: {
|
||||
backgroundColor: theme.palette.ui01,
|
||||
border: `1px solid ${theme.palette.ui03}`,
|
||||
boxShadow: '0px 4px 25px 4px rgba(20, 20, 20, 0.6)',
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'auto',
|
||||
minHeight: '200px',
|
||||
maxHeight: '80vh',
|
||||
marginTop: '64px',
|
||||
animation: `${keyframes`
|
||||
0% {
|
||||
margin-top: 85px
|
||||
}
|
||||
100% {
|
||||
margin-top: 64px
|
||||
}
|
||||
`} 0.2s forwards ease-out`,
|
||||
|
||||
'&.medium': {
|
||||
width: '400px'
|
||||
},
|
||||
|
||||
'&.large': {
|
||||
width: '664px'
|
||||
},
|
||||
|
||||
'&.unmount': {
|
||||
animation: `${keyframes`
|
||||
0% {
|
||||
margin-top: 64px
|
||||
}
|
||||
100% {
|
||||
margin-top: 40px
|
||||
}
|
||||
`} 0.15s forwards ease-in`
|
||||
},
|
||||
|
||||
'@media (max-width: 448px)': {
|
||||
width: '100% !important',
|
||||
maxHeight: 'initial',
|
||||
height: '100%',
|
||||
margin: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
animation: `${keyframes`
|
||||
0% {
|
||||
margin-top: 15px
|
||||
}
|
||||
100% {
|
||||
margin-top: 0
|
||||
}
|
||||
`} 0.2s forwards ease-out`,
|
||||
|
||||
'&.unmount': {
|
||||
animation: `${keyframes`
|
||||
0% {
|
||||
margin-top: 0
|
||||
}
|
||||
100% {
|
||||
margin-top: 15px
|
||||
}
|
||||
`} 0.15s forwards ease-in`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
focusLock: {
|
||||
zIndex: 1
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export interface IProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
description?: string;
|
||||
disableBackdropClose?: boolean;
|
||||
disableEnter?: boolean;
|
||||
disableEscape?: boolean;
|
||||
onClose?: () => void;
|
||||
size?: 'large' | 'medium';
|
||||
submit?: () => void;
|
||||
testId?: string;
|
||||
title?: string;
|
||||
titleKey?: string;
|
||||
}
|
||||
|
||||
const BaseDialog = ({
|
||||
children,
|
||||
className,
|
||||
description,
|
||||
disableBackdropClose,
|
||||
disableEnter,
|
||||
disableEscape,
|
||||
onClose,
|
||||
size = 'medium',
|
||||
submit,
|
||||
testId,
|
||||
title,
|
||||
titleKey
|
||||
}: IProps) => {
|
||||
const { classes, cx } = useStyles();
|
||||
const { isUnmounting } = useContext(DialogTransitionContext);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onBackdropClick = useCallback(() => {
|
||||
!disableBackdropClose && onClose?.();
|
||||
}, [ disableBackdropClose, onClose ]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !disableEscape) {
|
||||
onClose?.();
|
||||
}
|
||||
if (e.key === 'Enter' && !disableEnter) {
|
||||
submit?.();
|
||||
}
|
||||
}, [ disableEnter, onClose, submit ]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [ handleKeyDown ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { cx(classes.container, isUnmounting && 'unmount') }
|
||||
data-testid = { testId }>
|
||||
<div className = { classes.backdrop } />
|
||||
<FocusOn
|
||||
className = { classes.focusLock }
|
||||
onClickOutside = { onBackdropClick }
|
||||
returnFocus = {
|
||||
|
||||
// If we return the focus to an element outside the viewport the page will scroll to
|
||||
// this element which in our case is undesirable and the element is outside of the
|
||||
// viewport on purpose (to be hidden). For example if we return the focus to the toolbox
|
||||
// when it is hidden the whole page will move up in order to show the toolbox. This is
|
||||
// usually followed up with displaying the toolbox (because now it is on focus) but
|
||||
// because of the animation the whole scenario looks like jumping large video.
|
||||
isElementInTheViewport
|
||||
}>
|
||||
<div
|
||||
aria-description = { description }
|
||||
aria-label = { title ?? t(titleKey ?? '') }
|
||||
aria-modal = { true }
|
||||
className = { cx(classes.modal, isUnmounting && 'unmount', size, className) }
|
||||
data-autofocus = { true }
|
||||
role = 'dialog'
|
||||
tabIndex = { -1 }>
|
||||
{children}
|
||||
</div>
|
||||
</FocusOn>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseDialog;
|
||||
223
react/features/base/ui/components/web/Button.tsx
Normal file
223
react/features/base/ui/components/web/Button.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Icon from '../../../icons/components/Icon';
|
||||
import { BUTTON_TYPES } from '../../constants.web';
|
||||
import { IButtonProps } from '../types';
|
||||
|
||||
interface IProps extends IButtonProps {
|
||||
|
||||
/**
|
||||
* Class name used for additional styles.
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the button should be full width.
|
||||
*/
|
||||
fullWidth?: boolean;
|
||||
|
||||
/**
|
||||
* The id of the button.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the button is a submit form button.
|
||||
*/
|
||||
isSubmit?: boolean;
|
||||
|
||||
/**
|
||||
* Text to be displayed on the component.
|
||||
* Used when there's no labelKey.
|
||||
*/
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* Which size the button should be.
|
||||
*/
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
|
||||
/**
|
||||
* Data test id.
|
||||
*/
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
button: {
|
||||
backgroundColor: theme.palette.action01,
|
||||
color: theme.palette.text01,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: '10px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: 0,
|
||||
...theme.typography.bodyShortBold,
|
||||
transition: 'background .2s',
|
||||
cursor: 'pointer',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action01Hover
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.action01Active
|
||||
},
|
||||
|
||||
'&.focus-visible': {
|
||||
outline: 0,
|
||||
boxShadow: `0px 0px 0px 2px ${theme.palette.focus01}`
|
||||
},
|
||||
|
||||
'& div > svg': {
|
||||
fill: theme.palette.icon01
|
||||
}
|
||||
},
|
||||
|
||||
primary: {},
|
||||
|
||||
secondary: {
|
||||
backgroundColor: theme.palette.action02,
|
||||
color: theme.palette.text04,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action02Hover
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.action02Active
|
||||
},
|
||||
|
||||
'& div > svg': {
|
||||
fill: theme.palette.icon04
|
||||
}
|
||||
},
|
||||
|
||||
tertiary: {
|
||||
backgroundColor: theme.palette.action03,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action03Hover
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.action03Active
|
||||
}
|
||||
},
|
||||
|
||||
destructive: {
|
||||
backgroundColor: theme.palette.actionDanger,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.actionDangerHover
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.actionDangerActive
|
||||
}
|
||||
},
|
||||
|
||||
disabled: {
|
||||
backgroundColor: theme.palette.disabled01,
|
||||
color: theme.palette.text03,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.disabled01,
|
||||
color: theme.palette.text03
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.disabled01,
|
||||
color: theme.palette.text03
|
||||
},
|
||||
|
||||
'& div > svg': {
|
||||
fill: theme.palette.icon03
|
||||
}
|
||||
},
|
||||
|
||||
iconButton: {
|
||||
padding: theme.spacing(2)
|
||||
},
|
||||
|
||||
textWithIcon: {
|
||||
marginLeft: theme.spacing(2)
|
||||
},
|
||||
|
||||
small: {
|
||||
padding: '8px 16px',
|
||||
...theme.typography.labelBold,
|
||||
|
||||
'&.iconButton': {
|
||||
padding: theme.spacing(1)
|
||||
}
|
||||
},
|
||||
|
||||
medium: {},
|
||||
|
||||
large: {
|
||||
padding: '13px 16px',
|
||||
...theme.typography.bodyShortBoldLarge,
|
||||
|
||||
'&.iconButton': {
|
||||
padding: '12px'
|
||||
}
|
||||
},
|
||||
|
||||
fullWidth: {
|
||||
width: '100%'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const Button = React.forwardRef<any, any>(({
|
||||
accessibilityLabel,
|
||||
autoFocus = false,
|
||||
className,
|
||||
disabled,
|
||||
fullWidth,
|
||||
icon,
|
||||
id,
|
||||
isSubmit,
|
||||
label,
|
||||
labelKey,
|
||||
onClick = () => null,
|
||||
onKeyPress = () => null,
|
||||
size = 'medium',
|
||||
testId,
|
||||
type = BUTTON_TYPES.PRIMARY
|
||||
}: IProps, ref) => {
|
||||
const { classes: styles, cx } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label = { accessibilityLabel }
|
||||
autoFocus = { autoFocus }
|
||||
className = { cx(styles.button, styles[type],
|
||||
disabled && styles.disabled,
|
||||
icon && !(labelKey || label) && `${styles.iconButton} iconButton`,
|
||||
styles[size], fullWidth && styles.fullWidth, className) }
|
||||
data-testid = { testId }
|
||||
disabled = { disabled }
|
||||
{ ...(id ? { id } : {}) }
|
||||
onClick = { onClick }
|
||||
onKeyPress = { onKeyPress }
|
||||
ref = { ref }
|
||||
title = { accessibilityLabel }
|
||||
type = { isSubmit ? 'submit' : 'button' }>
|
||||
{icon && <Icon
|
||||
size = { 24 }
|
||||
src = { icon } />}
|
||||
{(labelKey || label) && <span className = { icon ? styles.textWithIcon : '' }>
|
||||
{labelKey ? t(labelKey) : label}
|
||||
</span>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export default Button;
|
||||
178
react/features/base/ui/components/web/Checkbox.tsx
Normal file
178
react/features/base/ui/components/web/Checkbox.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { isMobileBrowser } from '../../../environment/utils';
|
||||
import Icon from '../../../icons/components/Icon';
|
||||
import { IconCheck } from '../../../icons/svg';
|
||||
|
||||
interface ICheckboxProps {
|
||||
|
||||
/**
|
||||
* Whether the input is checked or not.
|
||||
*/
|
||||
checked?: boolean;
|
||||
|
||||
/**
|
||||
* Class name for additional styles.
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* Whether the input is disabled or not.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* The label of the input.
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* The name of the input.
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* Change callback.
|
||||
*/
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
formControl: {
|
||||
...theme.typography.bodyLongRegular,
|
||||
color: theme.palette.text01,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
|
||||
'&.is-mobile': {
|
||||
...theme.typography.bodyLongRegularLarge
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
disabled: {
|
||||
cursor: 'not-allowed'
|
||||
},
|
||||
|
||||
activeArea: {
|
||||
display: 'grid',
|
||||
placeContent: 'center',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
backgroundColor: 'transparent',
|
||||
marginRight: '15px',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
|
||||
'& input[type="checkbox"]': {
|
||||
appearance: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
margin: '3px',
|
||||
font: 'inherit',
|
||||
color: theme.palette.icon03,
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
border: `2px solid ${theme.palette.icon03}`,
|
||||
borderRadius: '3px',
|
||||
|
||||
display: 'grid',
|
||||
placeContent: 'center',
|
||||
|
||||
'&::before': {
|
||||
content: 'url("")',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
opacity: 0,
|
||||
backgroundColor: theme.palette.action01,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: 0,
|
||||
borderRadius: '3px',
|
||||
transition: '.2s'
|
||||
},
|
||||
|
||||
'&:checked::before': {
|
||||
opacity: 1
|
||||
},
|
||||
|
||||
'&:disabled': {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
borderColor: theme.palette.ui04,
|
||||
|
||||
'&::before': {
|
||||
backgroundColor: theme.palette.ui04
|
||||
}
|
||||
},
|
||||
|
||||
'&:checked+.checkmark': {
|
||||
opacity: 1
|
||||
}
|
||||
},
|
||||
|
||||
'& .checkmark': {
|
||||
position: 'absolute',
|
||||
left: '3px',
|
||||
top: '3px',
|
||||
opacity: 0,
|
||||
transition: '.2s'
|
||||
},
|
||||
|
||||
'&.is-mobile': {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
|
||||
'& input[type="checkbox"]': {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
|
||||
'&::before': {
|
||||
width: '24px',
|
||||
height: '24px'
|
||||
}
|
||||
},
|
||||
|
||||
'& .checkmark': {
|
||||
left: '11px',
|
||||
top: '10px'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const Checkbox = ({
|
||||
checked,
|
||||
className,
|
||||
disabled,
|
||||
label,
|
||||
name,
|
||||
onChange
|
||||
}: ICheckboxProps) => {
|
||||
const { classes: styles, cx, theme } = useStyles();
|
||||
const isMobile = isMobileBrowser();
|
||||
|
||||
return (
|
||||
<label className = { cx(styles.formControl, isMobile && 'is-mobile', className) }>
|
||||
<div className = { cx(styles.activeArea, isMobile && 'is-mobile', disabled && styles.disabled) }>
|
||||
<input
|
||||
checked = { checked }
|
||||
disabled = { disabled }
|
||||
name = { name }
|
||||
onChange = { onChange }
|
||||
type = 'checkbox' />
|
||||
<Icon
|
||||
aria-hidden = { true }
|
||||
className = 'checkmark'
|
||||
color = { disabled ? theme.palette.icon03 : theme.palette.icon01 }
|
||||
size = { 18 }
|
||||
src = { IconCheck } />
|
||||
</div>
|
||||
<div>{label}</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
61
react/features/base/ui/components/web/ClickableIcon.tsx
Normal file
61
react/features/base/ui/components/web/ClickableIcon.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { isMobileBrowser } from '../../../environment/utils';
|
||||
import Icon from '../../../icons/components/Icon';
|
||||
|
||||
interface IProps {
|
||||
accessibilityLabel: string;
|
||||
className?: string;
|
||||
icon: Function;
|
||||
id?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
button: {
|
||||
padding: '2px',
|
||||
backgroundColor: theme.palette.action03,
|
||||
border: 0,
|
||||
outline: 0,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.ui02
|
||||
},
|
||||
|
||||
'&.focus-visible': {
|
||||
outline: 0,
|
||||
boxShadow: `0px 0px 0px 2px ${theme.palette.focus01}`
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.ui03
|
||||
},
|
||||
|
||||
'&.is-mobile': {
|
||||
padding: '10px'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ClickableIcon = ({ accessibilityLabel, className, icon, id, onClick }: IProps) => {
|
||||
const { classes: styles, cx } = useStyles();
|
||||
const isMobile = isMobileBrowser();
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label = { accessibilityLabel }
|
||||
className = { cx(styles.button, isMobile && 'is-mobile', className) }
|
||||
id = { id }
|
||||
onClick = { onClick }>
|
||||
<Icon
|
||||
size = { 24 }
|
||||
src = { icon } />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClickableIcon;
|
||||
412
react/features/base/ui/components/web/ContextMenu.tsx
Normal file
412
react/features/base/ui/components/web/ContextMenu.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { KeyboardEvent, ReactNode,
|
||||
useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { FocusOn } from 'react-focus-on';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Drawer from '../../../../toolbox/components/web/Drawer';
|
||||
import JitsiPortal from '../../../../toolbox/components/web/JitsiPortal';
|
||||
import { showOverflowDrawer } from '../../../../toolbox/functions.web';
|
||||
import participantsPaneTheme from '../../../components/themes/participantsPaneTheme.json';
|
||||
import { spacing } from '../../Tokens';
|
||||
|
||||
|
||||
/**
|
||||
* Get a style property from a style declaration as a float.
|
||||
*
|
||||
* @param {CSSStyleDeclaration} styles - Style declaration.
|
||||
* @param {string} name - Property name.
|
||||
* @returns {number} Float value.
|
||||
*/
|
||||
const getFloatStyleProperty = (styles: CSSStyleDeclaration, name: string) =>
|
||||
parseFloat(styles.getPropertyValue(name));
|
||||
|
||||
/**
|
||||
* Gets the outer height of an element, including margins.
|
||||
*
|
||||
* @param {Element} element - Target element.
|
||||
* @returns {number} Computed height.
|
||||
*/
|
||||
const getComputedOuterHeight = (element: HTMLElement) => {
|
||||
const computedStyle = getComputedStyle(element);
|
||||
|
||||
return element.offsetHeight
|
||||
+ getFloatStyleProperty(computedStyle, 'margin-top')
|
||||
+ getFloatStyleProperty(computedStyle, 'margin-bottom');
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* ARIA attributes.
|
||||
*/
|
||||
[key: `aria-${string}`]: string;
|
||||
|
||||
/**
|
||||
* Accessibility label for menu container.
|
||||
*/
|
||||
accessibilityLabel?: string;
|
||||
|
||||
/**
|
||||
* To activate the FocusOn component.
|
||||
*/
|
||||
activateFocusTrap?: boolean;
|
||||
|
||||
/**
|
||||
* Children of the context menu.
|
||||
*/
|
||||
children: ReactNode;
|
||||
|
||||
/**
|
||||
* Class name for context menu. Used to overwrite default styles.
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* The entity for which the context menu is displayed.
|
||||
*/
|
||||
entity?: Object;
|
||||
|
||||
/**
|
||||
* Whether or not the menu is hidden. Used to overwrite the internal isHidden.
|
||||
*/
|
||||
hidden?: boolean;
|
||||
|
||||
/**
|
||||
* Optional id.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the menu is already in a drawer.
|
||||
*/
|
||||
inDrawer?: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not drawer should be open.
|
||||
*/
|
||||
isDrawerOpen?: boolean;
|
||||
|
||||
/**
|
||||
* Target elements against which positioning calculations are made.
|
||||
*/
|
||||
offsetTarget?: HTMLElement | null;
|
||||
|
||||
/**
|
||||
* Callback for click on an item in the menu.
|
||||
*/
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Callback for drawer close.
|
||||
*/
|
||||
onDrawerClose?: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Keydown handler.
|
||||
*/
|
||||
onKeyDown?: (e?: React.KeyboardEvent) => void;
|
||||
|
||||
/**
|
||||
* Callback for the mouse entering the component.
|
||||
*/
|
||||
onMouseEnter?: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Callback for the mouse leaving the component.
|
||||
*/
|
||||
onMouseLeave?: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Container role.
|
||||
*/
|
||||
role?: string;
|
||||
|
||||
/**
|
||||
* Tab index for the menu.
|
||||
*/
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
const MAX_HEIGHT = 400;
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
contextMenu: {
|
||||
backgroundColor: theme.palette.ui01,
|
||||
border: `1px solid ${theme.palette.ui04}`,
|
||||
borderRadius: `${Number(theme.shape.borderRadius)}px`,
|
||||
boxShadow: '0px 1px 2px rgba(41, 41, 41, 0.25)',
|
||||
color: theme.palette.text01,
|
||||
...theme.typography.bodyShortRegular,
|
||||
marginTop: '48px',
|
||||
position: 'absolute',
|
||||
right: `${participantsPaneTheme.panePadding}px`,
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
overflowY: 'auto',
|
||||
padding: `${theme.spacing(2)} 0`
|
||||
},
|
||||
|
||||
contextMenuHidden: {
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden'
|
||||
},
|
||||
|
||||
drawer: {
|
||||
paddingTop: '16px',
|
||||
|
||||
'& > div': {
|
||||
...theme.typography.bodyShortRegularLarge,
|
||||
|
||||
'& svg': {
|
||||
fill: theme.palette.icon01
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ContextMenu = ({
|
||||
accessibilityLabel,
|
||||
activateFocusTrap = false,
|
||||
children,
|
||||
className,
|
||||
entity,
|
||||
hidden,
|
||||
id,
|
||||
inDrawer,
|
||||
isDrawerOpen,
|
||||
offsetTarget,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onDrawerClose,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
role,
|
||||
tabIndex,
|
||||
...aria
|
||||
}: IProps) => {
|
||||
const [ isHidden, setIsHidden ] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { classes: styles, cx } = useStyles();
|
||||
const _overflowDrawer = useSelector(showOverflowDrawer);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (_overflowDrawer) {
|
||||
return;
|
||||
}
|
||||
if (entity && offsetTarget
|
||||
&& containerRef.current
|
||||
&& offsetTarget?.offsetParent
|
||||
&& offsetTarget.offsetParent instanceof HTMLElement
|
||||
) {
|
||||
const { current: container } = containerRef;
|
||||
|
||||
// make sure the max height is not set
|
||||
container.style.maxHeight = 'none';
|
||||
const { offsetTop, offsetParent: { offsetHeight, scrollTop } } = offsetTarget;
|
||||
let outerHeight = getComputedOuterHeight(container);
|
||||
let height = Math.min(MAX_HEIGHT, outerHeight);
|
||||
|
||||
if (offsetTop + height > offsetHeight + scrollTop && height > offsetTop) {
|
||||
// top offset and + padding + border
|
||||
container.style.maxHeight = `${offsetTop - ((spacing[2] * 2) + 2)}px`;
|
||||
}
|
||||
|
||||
// get the height after style changes
|
||||
outerHeight = getComputedOuterHeight(container);
|
||||
height = Math.min(MAX_HEIGHT, outerHeight);
|
||||
|
||||
container.style.top = offsetTop + height > offsetHeight + scrollTop
|
||||
? `${offsetTop - outerHeight}`
|
||||
: `${offsetTop}`;
|
||||
|
||||
setIsHidden(false);
|
||||
} else {
|
||||
hidden === undefined && setIsHidden(true);
|
||||
}
|
||||
}, [ entity, offsetTarget, _overflowDrawer ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hidden !== undefined) {
|
||||
setIsHidden(hidden);
|
||||
}
|
||||
}, [ hidden ]);
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
const { current: listRef } = containerRef;
|
||||
const currentFocusElement = document.activeElement;
|
||||
|
||||
const moveFocus = (
|
||||
list: Element | null,
|
||||
currentFocus: Element | null,
|
||||
traversalFunction: (
|
||||
list: Element | null,
|
||||
currentFocus: Element | null
|
||||
) => Element | null
|
||||
) => {
|
||||
let wrappedOnce = false;
|
||||
let nextFocus = traversalFunction(list, currentFocus);
|
||||
|
||||
/* eslint-disable no-unmodified-loop-condition */
|
||||
while (list && nextFocus) {
|
||||
// Prevent infinite loop.
|
||||
if (nextFocus === list.firstChild) {
|
||||
if (wrappedOnce) {
|
||||
return;
|
||||
}
|
||||
wrappedOnce = true;
|
||||
}
|
||||
|
||||
// Same logic as useAutocomplete.js
|
||||
const nextFocusDisabled
|
||||
/* eslint-disable no-extra-parens */
|
||||
= (nextFocus as HTMLInputElement).disabled
|
||||
|| nextFocus.getAttribute('aria-disabled') === 'true';
|
||||
|
||||
if (!nextFocus.hasAttribute('tabindex') || nextFocusDisabled) {
|
||||
// Move to the next element.
|
||||
nextFocus = traversalFunction(list, nextFocus);
|
||||
} else {
|
||||
/* eslint-disable no-extra-parens */
|
||||
(nextFocus as HTMLElement).focus();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const previousItem = (
|
||||
list: Element | null,
|
||||
item: Element | null
|
||||
): Element | null => {
|
||||
/**
|
||||
* To find the last child of the list.
|
||||
*
|
||||
* @param {Element | null} element - Element.
|
||||
* @returns {Element | null}
|
||||
*/
|
||||
function lastChild(element: Element | null): Element | null {
|
||||
while (element?.lastElementChild) {
|
||||
/* eslint-disable no-param-reassign */
|
||||
element = element.lastElementChild;
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
if (!list) {
|
||||
return null;
|
||||
}
|
||||
if (list === item) {
|
||||
return list.lastElementChild;
|
||||
}
|
||||
if (item?.previousElementSibling) {
|
||||
return lastChild(item.previousElementSibling);
|
||||
}
|
||||
if (item && item?.parentElement !== list) {
|
||||
return item.parentElement;
|
||||
}
|
||||
|
||||
return lastChild(list.lastElementChild);
|
||||
};
|
||||
|
||||
const nextItem = (
|
||||
list: Element | null,
|
||||
item: Element | null
|
||||
): Element | null => {
|
||||
if (!list) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (list === item) {
|
||||
return list.firstElementChild;
|
||||
}
|
||||
if (item?.firstElementChild) {
|
||||
return item.firstElementChild;
|
||||
}
|
||||
if (item?.nextElementSibling) {
|
||||
return item.nextElementSibling;
|
||||
}
|
||||
while (item && item.parentElement !== list) {
|
||||
/* eslint-disable no-param-reassign */
|
||||
item = item.parentElement;
|
||||
if (item?.nextElementSibling) {
|
||||
return item.nextElementSibling;
|
||||
}
|
||||
}
|
||||
|
||||
return list?.firstElementChild;
|
||||
};
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
// Close the menu
|
||||
setIsHidden(true);
|
||||
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
// Move focus to the previous menu item
|
||||
event.preventDefault();
|
||||
moveFocus(listRef, currentFocusElement, previousItem);
|
||||
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
// Move focus to the next menu item
|
||||
event.preventDefault();
|
||||
moveFocus(listRef, currentFocusElement, nextItem);
|
||||
}
|
||||
}, [ containerRef ]);
|
||||
|
||||
const removeFocus = useCallback(() => {
|
||||
onDrawerClose?.();
|
||||
}, [ onMouseLeave ]);
|
||||
|
||||
if (_overflowDrawer && inDrawer) {
|
||||
return (<div
|
||||
className = { styles.drawer }
|
||||
onClick = { onDrawerClose }>
|
||||
{children}
|
||||
</div>);
|
||||
}
|
||||
|
||||
return _overflowDrawer
|
||||
? <JitsiPortal>
|
||||
<Drawer
|
||||
isOpen = { Boolean(isDrawerOpen && _overflowDrawer) }
|
||||
onClose = { onDrawerClose }>
|
||||
<div
|
||||
className = { styles.drawer }
|
||||
onClick = { onDrawerClose }>
|
||||
{children}
|
||||
</div>
|
||||
</Drawer>
|
||||
</JitsiPortal>
|
||||
: <FocusOn
|
||||
|
||||
// Use the `enabled` prop instead of conditionally rendering ReactFocusOn
|
||||
// to prevent UI stutter on dialog appearance. It seems the focus guards generated annoy
|
||||
// our DialogPortal positioning calculations.
|
||||
enabled = { activateFocusTrap && !isHidden }
|
||||
onClickOutside = { removeFocus }
|
||||
onEscapeKey = { removeFocus }>
|
||||
<div
|
||||
{ ...aria }
|
||||
aria-label = { accessibilityLabel }
|
||||
className = { cx(styles.contextMenu,
|
||||
isHidden && styles.contextMenuHidden,
|
||||
className
|
||||
) }
|
||||
id = { id }
|
||||
onClick = { onClick }
|
||||
onKeyDown = { onKeyDown ?? handleKeyDown }
|
||||
onMouseEnter = { onMouseEnter }
|
||||
onMouseLeave = { onMouseLeave }
|
||||
ref = { containerRef }
|
||||
role = { role }
|
||||
tabIndex = { tabIndex }>
|
||||
{children}
|
||||
</div>
|
||||
</FocusOn >;
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
272
react/features/base/ui/components/web/ContextMenuItem.tsx
Normal file
272
react/features/base/ui/components/web/ContextMenuItem.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import React, { ReactNode, useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { showOverflowDrawer } from '../../../../toolbox/functions.web';
|
||||
import Icon from '../../../icons/components/Icon';
|
||||
import { TEXT_OVERFLOW_TYPES } from '../../constants.any';
|
||||
|
||||
import TextWithOverflow from './TextWithOverflow';
|
||||
|
||||
export interface IProps {
|
||||
|
||||
/**
|
||||
* Label used for accessibility.
|
||||
*/
|
||||
accessibilityLabel?: string;
|
||||
|
||||
/**
|
||||
* The context menu item background color.
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
|
||||
/**
|
||||
* Component children.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
|
||||
/**
|
||||
* CSS class name used for custom styles.
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* Id of dom element controlled by this item. Matches aria-controls.
|
||||
* Useful if you need this item as a tab element.
|
||||
*
|
||||
*/
|
||||
controls?: string;
|
||||
|
||||
/**
|
||||
* Custom icon. If used, the icon prop is ignored.
|
||||
* Used to allow custom children instead of just the default icons.
|
||||
*/
|
||||
customIcon?: ReactNode;
|
||||
|
||||
/**
|
||||
* Whether or not the action is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Default icon for action.
|
||||
*/
|
||||
icon?: Function;
|
||||
|
||||
/**
|
||||
* Id of the action container.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Click handler.
|
||||
*/
|
||||
onClick?: (e?: React.MouseEvent<any>) => void;
|
||||
|
||||
/**
|
||||
* Keydown handler.
|
||||
*/
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
|
||||
/**
|
||||
* Keypress handler.
|
||||
*/
|
||||
onKeyPress?: (e?: React.KeyboardEvent) => void;
|
||||
|
||||
/**
|
||||
* Overflow type for item text.
|
||||
*/
|
||||
overflowType?: TEXT_OVERFLOW_TYPES;
|
||||
|
||||
/**
|
||||
* You can use this item as a tab. Defaults to button if not set.
|
||||
*
|
||||
* If no onClick handler is provided, we assume the context menu item is
|
||||
* not interactive and no role will be set.
|
||||
*/
|
||||
role?: 'tab' | 'button' | 'menuitem';
|
||||
|
||||
/**
|
||||
* Whether the item is marked as selected.
|
||||
*/
|
||||
selected?: boolean;
|
||||
|
||||
/**
|
||||
* TestId of the element, if any.
|
||||
*/
|
||||
testId?: string;
|
||||
|
||||
/**
|
||||
* Action text.
|
||||
*/
|
||||
text?: string;
|
||||
|
||||
/**
|
||||
* Class name for the text.
|
||||
*/
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
contextMenuItem: {
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
minHeight: '40px',
|
||||
padding: '10px 16px',
|
||||
boxSizing: 'border-box',
|
||||
|
||||
'& > *:not(:last-child)': {
|
||||
marginRight: theme.spacing(3)
|
||||
},
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.ui02
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.ui03
|
||||
},
|
||||
|
||||
'&.focus-visible': {
|
||||
boxShadow: `inset 0 0 0 2px ${theme.palette.action01Hover}`
|
||||
}
|
||||
},
|
||||
|
||||
selected: {
|
||||
borderLeft: `3px solid ${theme.palette.action01Hover}`,
|
||||
paddingLeft: '13px',
|
||||
backgroundColor: theme.palette.ui02
|
||||
},
|
||||
|
||||
contextMenuItemDisabled: {
|
||||
pointerEvents: 'none'
|
||||
},
|
||||
|
||||
contextMenuItemIconDisabled: {
|
||||
'& svg': {
|
||||
fill: `${theme.palette.text03} !important`
|
||||
}
|
||||
},
|
||||
|
||||
contextMenuItemLabelDisabled: {
|
||||
color: theme.palette.text03,
|
||||
|
||||
'&:hover': {
|
||||
background: 'none'
|
||||
},
|
||||
|
||||
'& svg': {
|
||||
fill: theme.palette.text03
|
||||
}
|
||||
},
|
||||
|
||||
contextMenuItemDrawer: {
|
||||
padding: '13px 16px'
|
||||
},
|
||||
|
||||
contextMenuItemIcon: {
|
||||
'& svg': {
|
||||
fill: theme.palette.icon01
|
||||
}
|
||||
},
|
||||
|
||||
text: {
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text01
|
||||
},
|
||||
|
||||
drawerText: {
|
||||
...theme.typography.bodyShortRegularLarge
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ContextMenuItem = ({
|
||||
accessibilityLabel,
|
||||
backgroundColor,
|
||||
children,
|
||||
className,
|
||||
controls,
|
||||
customIcon,
|
||||
disabled,
|
||||
id,
|
||||
icon,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onKeyPress,
|
||||
overflowType,
|
||||
role = 'button',
|
||||
selected,
|
||||
testId,
|
||||
text,
|
||||
textClassName }: IProps) => {
|
||||
const { classes: styles, cx } = useStyles();
|
||||
const _overflowDrawer: boolean = useSelector(showOverflowDrawer);
|
||||
const style = backgroundColor ? { backgroundColor } : {};
|
||||
const onKeyPressHandler = useCallback(e => {
|
||||
// only trigger the fallback behavior (onClick) if we dont have any explicit keyboard event handler
|
||||
if (onClick && !onKeyPress && !onKeyDown && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
}
|
||||
|
||||
if (onKeyPress) {
|
||||
onKeyPress(e);
|
||||
}
|
||||
}, [ onClick, onKeyPress, onKeyDown ]);
|
||||
|
||||
let tabIndex: undefined | 0 | -1;
|
||||
|
||||
if (role === 'tab') {
|
||||
tabIndex = selected ? 0 : -1;
|
||||
}
|
||||
|
||||
if ((role === 'button' || role === 'menuitem') && !disabled) {
|
||||
tabIndex = 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-controls = { controls }
|
||||
aria-disabled = { disabled }
|
||||
aria-label = { accessibilityLabel || undefined }
|
||||
aria-selected = { role === 'tab' ? selected : undefined }
|
||||
className = { cx(styles.contextMenuItem,
|
||||
_overflowDrawer && styles.contextMenuItemDrawer,
|
||||
disabled && styles.contextMenuItemDisabled,
|
||||
selected && styles.selected,
|
||||
className
|
||||
) }
|
||||
data-testid = { testId }
|
||||
id = { id }
|
||||
key = { text }
|
||||
onClick = { disabled ? undefined : onClick }
|
||||
onKeyDown = { disabled ? undefined : onKeyDown }
|
||||
onKeyPress = { disabled ? undefined : onKeyPressHandler }
|
||||
role = { onClick ? role : undefined }
|
||||
style = { style }
|
||||
tabIndex = { onClick ? tabIndex : undefined }>
|
||||
{customIcon ? customIcon
|
||||
: icon && <Icon
|
||||
className = { cx(styles.contextMenuItemIcon,
|
||||
disabled && styles.contextMenuItemIconDisabled) }
|
||||
size = { 20 }
|
||||
src = { icon } />}
|
||||
{text && (
|
||||
<TextWithOverflow
|
||||
className = { cx(styles.text,
|
||||
_overflowDrawer && styles.drawerText,
|
||||
disabled && styles.contextMenuItemLabelDisabled,
|
||||
textClassName) }
|
||||
overflowType = { overflowType } >
|
||||
{text}
|
||||
</TextWithOverflow>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenuItem;
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import ContextMenuItem, { IProps as ItemProps } from './ContextMenuItem';
|
||||
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* List of actions in this group.
|
||||
*/
|
||||
actions?: Array<ItemProps>;
|
||||
|
||||
/**
|
||||
* The children of the component.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
|
||||
/**
|
||||
* The optional role of the component.
|
||||
*/
|
||||
role?: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
contextMenuItemGroup: {
|
||||
'&:not(:empty)': {
|
||||
padding: `${theme.spacing(2)} 0`
|
||||
},
|
||||
|
||||
'& + &:not(:empty)': {
|
||||
borderTop: `1px solid ${theme.palette.ui03}`
|
||||
},
|
||||
|
||||
'&:first-of-type': {
|
||||
paddingTop: 0
|
||||
},
|
||||
|
||||
'&:last-of-type': {
|
||||
paddingBottom: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ContextMenuItemGroup = ({
|
||||
actions,
|
||||
children,
|
||||
...rest
|
||||
}: IProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { styles.contextMenuItemGroup }
|
||||
{ ...rest }>
|
||||
{children}
|
||||
{actions?.map(actionProps => (
|
||||
<ContextMenuItem
|
||||
key = { actionProps.text }
|
||||
{ ...actionProps } />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenuItemGroup;
|
||||
177
react/features/base/ui/components/web/Dialog.tsx
Normal file
177
react/features/base/ui/components/web/Dialog.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { hideDialog } from '../../../dialog/actions';
|
||||
import { IconCloseLarge } from '../../../icons/svg';
|
||||
import { operatesWithEnterKey } from '../../functions.web';
|
||||
|
||||
import BaseDialog, { IProps as IBaseDialogProps } from './BaseDialog';
|
||||
import Button from './Button';
|
||||
import ClickableIcon from './ClickableIcon';
|
||||
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
header: {
|
||||
width: '100%',
|
||||
padding: '24px',
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
|
||||
title: {
|
||||
color: theme.palette.text01,
|
||||
...theme.typography.heading5,
|
||||
margin: 0,
|
||||
padding: 0
|
||||
},
|
||||
|
||||
content: {
|
||||
height: 'auto',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: '0 24px',
|
||||
overflowX: 'hidden',
|
||||
minHeight: '40px',
|
||||
|
||||
'@media (max-width: 448px)': {
|
||||
height: '100%'
|
||||
}
|
||||
},
|
||||
|
||||
footer: {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
padding: '24px',
|
||||
|
||||
'& button:last-child': {
|
||||
marginLeft: '16px'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
interface IDialogProps extends IBaseDialogProps {
|
||||
back?: {
|
||||
hidden?: boolean;
|
||||
onClick?: () => void;
|
||||
translationKey?: string;
|
||||
};
|
||||
cancel?: {
|
||||
hidden?: boolean;
|
||||
translationKey?: string;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
disableAutoHideOnSubmit?: boolean;
|
||||
hideCloseButton?: boolean;
|
||||
ok?: {
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
translationKey?: string;
|
||||
};
|
||||
onCancel?: () => void;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
const Dialog = ({
|
||||
back = { hidden: true },
|
||||
cancel = { translationKey: 'dialog.Cancel' },
|
||||
children,
|
||||
className,
|
||||
description,
|
||||
disableAutoHideOnSubmit = false,
|
||||
disableBackdropClose,
|
||||
hideCloseButton,
|
||||
disableEnter,
|
||||
disableEscape,
|
||||
ok = { translationKey: 'dialog.Ok' },
|
||||
onCancel,
|
||||
onSubmit,
|
||||
size,
|
||||
testId,
|
||||
title,
|
||||
titleKey
|
||||
}: IDialogProps) => {
|
||||
const { classes } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(hideDialog());
|
||||
onCancel?.();
|
||||
}, [ onCancel ]);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if ((document.activeElement && !operatesWithEnterKey(document.activeElement)) || !document.activeElement) {
|
||||
!disableAutoHideOnSubmit && dispatch(hideDialog());
|
||||
onSubmit?.();
|
||||
}
|
||||
}, [ onSubmit ]);
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className = { className }
|
||||
description = { description }
|
||||
disableBackdropClose = { disableBackdropClose }
|
||||
disableEnter = { disableEnter }
|
||||
disableEscape = { disableEscape }
|
||||
onClose = { onClose }
|
||||
size = { size }
|
||||
submit = { submit }
|
||||
testId = { testId }
|
||||
title = { title }
|
||||
titleKey = { titleKey }>
|
||||
<div className = { classes.header }>
|
||||
<h1
|
||||
className = { classes.title }
|
||||
id = 'dialog-title'>
|
||||
{title ?? t(titleKey ?? '')}
|
||||
</h1>
|
||||
{!hideCloseButton && (
|
||||
<ClickableIcon
|
||||
accessibilityLabel = { t('dialog.accessibilityLabel.close') }
|
||||
icon = { IconCloseLarge }
|
||||
id = 'modal-header-close-button'
|
||||
onClick = { onClose } />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className = { classes.content }
|
||||
data-autofocus-inside = 'true'>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className = { classes.footer }
|
||||
data-autofocus-inside = 'true'>
|
||||
{!back.hidden && <Button
|
||||
accessibilityLabel = { t(back.translationKey ?? '') }
|
||||
labelKey = { back.translationKey }
|
||||
// eslint-disable-next-line react/jsx-handler-names
|
||||
onClick = { back.onClick }
|
||||
type = 'secondary' />}
|
||||
{!cancel.hidden && <Button
|
||||
accessibilityLabel = { t(cancel.translationKey ?? '') }
|
||||
labelKey = { cancel.translationKey }
|
||||
onClick = { onClose }
|
||||
type = 'tertiary' />}
|
||||
{!ok.hidden && <Button
|
||||
accessibilityLabel = { t(ok.translationKey ?? '') }
|
||||
disabled = { ok.disabled }
|
||||
id = 'modal-dialog-ok-button'
|
||||
isSubmit = { true }
|
||||
labelKey = { ok.translationKey }
|
||||
{ ...(!ok.disabled && { onClick: submit }) } />}
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dialog;
|
||||
96
react/features/base/ui/components/web/DialogContainer.tsx
Normal file
96
react/features/base/ui/components/web/DialogContainer.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { Component, ComponentType } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import JitsiPortal from '../../../../toolbox/components/web/JitsiPortal';
|
||||
import { showOverflowDrawer } from '../../../../toolbox/functions.web';
|
||||
|
||||
import DialogTransition from './DialogTransition';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The component to render.
|
||||
*/
|
||||
_component?: ComponentType;
|
||||
|
||||
/**
|
||||
* The props to pass to the component that will be rendered.
|
||||
*/
|
||||
_componentProps?: Object;
|
||||
|
||||
/**
|
||||
* Whether the overflow drawer should be used.
|
||||
*/
|
||||
_overflowDrawer: boolean;
|
||||
|
||||
/**
|
||||
* True if the UI is in a compact state where we don't show dialogs.
|
||||
*/
|
||||
_reducedUI: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a DialogContainer responsible for showing all dialogs. Necessary
|
||||
* for supporting @atlaskit's modal animations.
|
||||
*
|
||||
*/
|
||||
class DialogContainer extends Component<IProps> {
|
||||
|
||||
/**
|
||||
* Returns the dialog to be displayed.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement|null}
|
||||
*/
|
||||
_renderDialogContent() {
|
||||
const {
|
||||
_component: component,
|
||||
_reducedUI: reducedUI
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
component && !reducedUI
|
||||
? React.createElement(component, this.props._componentProps)
|
||||
: null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<DialogTransition>
|
||||
{this.props._overflowDrawer
|
||||
? <JitsiPortal>{this._renderDialogContent()}</JitsiPortal>
|
||||
: this._renderDialogContent()}
|
||||
</DialogTransition>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated
|
||||
* {@code AbstractDialogContainer}'s props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
const stateFeaturesBaseDialog = state['features/base/dialog'];
|
||||
const { reducedUI } = state['features/base/responsive-ui'];
|
||||
const overflowDrawer = showOverflowDrawer(state);
|
||||
|
||||
return {
|
||||
_component: stateFeaturesBaseDialog.component,
|
||||
_componentProps: stateFeaturesBaseDialog.componentProps,
|
||||
_overflowDrawer: overflowDrawer,
|
||||
_reducedUI: reducedUI
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(DialogContainer);
|
||||
39
react/features/base/ui/components/web/DialogTransition.tsx
Normal file
39
react/features/base/ui/components/web/DialogTransition.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
|
||||
export const DialogTransitionContext = React.createContext({ isUnmounting: false });
|
||||
|
||||
type TimeoutType = ReturnType<typeof setTimeout>;
|
||||
|
||||
const DialogTransition = ({ children }: { children: ReactElement | null; }) => {
|
||||
const [ childrenToRender, setChildrenToRender ] = useState(children);
|
||||
const [ isUnmounting, setIsUnmounting ] = useState(false);
|
||||
const [ timeoutID, setTimeoutID ] = useState<TimeoutType | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (children === null) {
|
||||
setIsUnmounting(true);
|
||||
if (typeof timeoutID === 'undefined') {
|
||||
setTimeoutID(setTimeout(() => {
|
||||
setChildrenToRender(children);
|
||||
setIsUnmounting(false);
|
||||
setTimeoutID(undefined);
|
||||
}, 150));
|
||||
}
|
||||
} else {
|
||||
if (typeof timeoutID !== 'undefined') {
|
||||
clearTimeout(timeoutID);
|
||||
setTimeoutID(undefined);
|
||||
setIsUnmounting(false);
|
||||
}
|
||||
setChildrenToRender(children);
|
||||
}
|
||||
}, [ children ]);
|
||||
|
||||
return (
|
||||
<DialogTransitionContext.Provider value = {{ isUnmounting }}>
|
||||
{childrenToRender}
|
||||
</DialogTransitionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogTransition;
|
||||
418
react/features/base/ui/components/web/DialogWithTabs.tsx
Normal file
418
react/features/base/ui/components/web/DialogWithTabs.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { hideDialog } from '../../../dialog/actions';
|
||||
import { IconArrowBack, IconCloseLarge } from '../../../icons/svg';
|
||||
|
||||
import BaseDialog, { IProps as IBaseProps } from './BaseDialog';
|
||||
import Button from './Button';
|
||||
import ClickableIcon from './ClickableIcon';
|
||||
import ContextMenuItem from './ContextMenuItem';
|
||||
|
||||
const MOBILE_BREAKPOINT = 607;
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
dialog: {
|
||||
flexDirection: 'row',
|
||||
height: '560px',
|
||||
|
||||
'@media (min-width: 608px) and (max-width: 712px)': {
|
||||
width: '560px'
|
||||
},
|
||||
|
||||
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0
|
||||
},
|
||||
|
||||
'@media (max-width: 448px)': {
|
||||
height: '100%'
|
||||
}
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: '211px',
|
||||
maxWidth: '100%',
|
||||
borderRight: `1px solid ${theme.palette.ui03}`,
|
||||
|
||||
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
|
||||
width: '100%',
|
||||
borderRight: 'none'
|
||||
}
|
||||
},
|
||||
|
||||
menuItemMobile: {
|
||||
paddingLeft: '24px'
|
||||
},
|
||||
|
||||
titleContainer: {
|
||||
margin: 0,
|
||||
padding: '24px',
|
||||
paddingRight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
|
||||
padding: '16px 24px'
|
||||
}
|
||||
},
|
||||
|
||||
title: {
|
||||
...theme.typography.heading5,
|
||||
color: `${theme.palette.text01} !important`,
|
||||
margin: 0,
|
||||
padding: 0
|
||||
},
|
||||
|
||||
contentContainer: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
padding: '24px',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
|
||||
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
|
||||
padding: '0'
|
||||
}
|
||||
},
|
||||
|
||||
buttonContainer: {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
flexGrow: 0,
|
||||
|
||||
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 24px'
|
||||
}
|
||||
},
|
||||
|
||||
backContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row-reverse',
|
||||
alignItems: 'center',
|
||||
|
||||
'& > button': {
|
||||
marginRight: '24px'
|
||||
}
|
||||
},
|
||||
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
|
||||
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
|
||||
padding: '0 24px'
|
||||
}
|
||||
},
|
||||
|
||||
header: {
|
||||
order: -1,
|
||||
paddingBottom: theme.spacing(4)
|
||||
},
|
||||
|
||||
footer: {
|
||||
justifyContent: 'flex-end',
|
||||
paddingTop: theme.spacing(4),
|
||||
|
||||
'& button:last-child': {
|
||||
marginLeft: '16px'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
interface IObject {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IDialogTab<P> {
|
||||
cancel?: Function;
|
||||
className?: string;
|
||||
component: ComponentType<any>;
|
||||
icon: Function;
|
||||
labelKey: string;
|
||||
name: string;
|
||||
props?: IObject;
|
||||
propsUpdateFunction?: (tabState: IObject, newProps: P, tabStates?: (IObject | undefined)[]) => P;
|
||||
submit?: Function;
|
||||
}
|
||||
|
||||
interface IProps extends IBaseProps {
|
||||
defaultTab?: string;
|
||||
tabs: IDialogTab<any>[];
|
||||
}
|
||||
|
||||
const DialogWithTabs = ({
|
||||
className,
|
||||
defaultTab,
|
||||
titleKey,
|
||||
tabs
|
||||
}: IProps) => {
|
||||
const { classes, cx } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const [ selectedTab, setSelectedTab ] = useState<string | undefined>(defaultTab ?? tabs[0].name);
|
||||
const [ userSelected, setUserSelected ] = useState(false);
|
||||
const [ tabStates, setTabStates ] = useState(tabs.map(tab => tab.props));
|
||||
const videoSpaceWidth = useSelector((state: IReduxState) => state['features/base/responsive-ui'].videoSpaceWidth);
|
||||
const [ isMobile, setIsMobile ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoSpaceWidth <= MOBILE_BREAKPOINT) {
|
||||
!isMobile && setIsMobile(true);
|
||||
} else {
|
||||
isMobile && setIsMobile(false);
|
||||
}
|
||||
}, [ videoSpaceWidth, isMobile ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setSelectedTab(defaultTab);
|
||||
} else {
|
||||
setSelectedTab(defaultTab ?? tabs[0].name);
|
||||
}
|
||||
}, [ isMobile ]);
|
||||
|
||||
const onUserSelection = useCallback((tabName?: string) => {
|
||||
setUserSelected(true);
|
||||
setSelectedTab(tabName);
|
||||
}, []);
|
||||
|
||||
const back = useCallback(() => {
|
||||
onUserSelection(undefined);
|
||||
}, []);
|
||||
|
||||
|
||||
// the userSelected state is used to prevent setting focus when the user
|
||||
// didn't actually interact (for the first rendering for example)
|
||||
useEffect(() => {
|
||||
if (userSelected) {
|
||||
document.querySelector<HTMLElement>(isMobile
|
||||
? `.${classes.title}`
|
||||
: `#${`dialogtab-button-${selectedTab}`}`
|
||||
)?.focus();
|
||||
setUserSelected(false);
|
||||
}
|
||||
}, [ isMobile, userSelected, selectedTab ]);
|
||||
|
||||
const onClose = useCallback((isCancel = true) => {
|
||||
if (isCancel) {
|
||||
tabs.forEach(({ cancel }) => {
|
||||
cancel && dispatch(cancel());
|
||||
});
|
||||
}
|
||||
dispatch(hideDialog());
|
||||
}, []);
|
||||
|
||||
const onClick = useCallback((tabName: string) => () => {
|
||||
onUserSelection(tabName);
|
||||
}, []);
|
||||
|
||||
const onTabKeyDown = useCallback((index: number) => (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
let newTab: IDialogTab<any> | null = null;
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
newTab = index === 0 ? tabs[tabs.length - 1] : tabs[index - 1];
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
newTab = index === tabs.length - 1 ? tabs[0] : tabs[index + 1];
|
||||
}
|
||||
|
||||
if (newTab !== null) {
|
||||
onUserSelection(newTab.name);
|
||||
}
|
||||
}, [ tabs.length ]);
|
||||
|
||||
const onMobileKeyDown = useCallback((tabName: string) => (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
onUserSelection(tabName);
|
||||
}
|
||||
}, [ classes.contentContainer ]);
|
||||
|
||||
const getTabProps = (tabId: number) => {
|
||||
const tabConfiguration = tabs[tabId];
|
||||
const currentTabState = tabStates[tabId];
|
||||
|
||||
if (tabConfiguration.propsUpdateFunction) {
|
||||
return tabConfiguration.propsUpdateFunction(
|
||||
currentTabState ?? {},
|
||||
tabConfiguration.props ?? {},
|
||||
tabStates);
|
||||
}
|
||||
|
||||
return { ...currentTabState };
|
||||
};
|
||||
|
||||
const onTabStateChange = useCallback((tabId: number, state: IObject) => {
|
||||
const newTabStates = [ ...tabStates ];
|
||||
|
||||
newTabStates[tabId] = state;
|
||||
setTabStates(newTabStates);
|
||||
}, [ tabStates ]);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
tabs.forEach(({ submit }, idx) => {
|
||||
submit?.(tabStates[idx]);
|
||||
});
|
||||
onClose(false);
|
||||
}, [ tabs, tabStates ]);
|
||||
|
||||
const selectedTabIndex = useMemo(() => {
|
||||
if (selectedTab) {
|
||||
return tabs.findIndex(tab => tab.name === selectedTab);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [ selectedTab ]);
|
||||
|
||||
const selectedTabComponent = useMemo(() => {
|
||||
if (selectedTabIndex !== null) {
|
||||
const TabComponent = tabs[selectedTabIndex].component;
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { tabs[selectedTabIndex].className }
|
||||
key = { tabs[selectedTabIndex].name }>
|
||||
<TabComponent
|
||||
onTabStateChange = { onTabStateChange }
|
||||
tabId = { selectedTabIndex }
|
||||
{ ...getTabProps(selectedTabIndex) } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [ selectedTabIndex, tabStates ]);
|
||||
|
||||
const closeIcon = useMemo(() => (
|
||||
<ClickableIcon
|
||||
accessibilityLabel = { t('dialog.accessibilityLabel.close') }
|
||||
icon = { IconCloseLarge }
|
||||
id = 'modal-header-close-button'
|
||||
onClick = { onClose } />
|
||||
), [ onClose ]);
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className = { cx(classes.dialog, className) }
|
||||
onClose = { onClose }
|
||||
size = 'large'
|
||||
titleKey = { titleKey }>
|
||||
{(!isMobile || !selectedTab) && (
|
||||
<div
|
||||
aria-orientation = 'vertical'
|
||||
className = { classes.sidebar }
|
||||
role = { isMobile ? undefined : 'tablist' }>
|
||||
<div className = { classes.titleContainer }>
|
||||
<h1
|
||||
className = { classes.title }
|
||||
tabIndex = { -1 }>
|
||||
{t(titleKey ?? '')}
|
||||
</h1>
|
||||
{isMobile && closeIcon}
|
||||
</div>
|
||||
{tabs.map((tab, index) => {
|
||||
const label = t(tab.labelKey);
|
||||
|
||||
/**
|
||||
* When not on mobile, the items behave as tabs,
|
||||
* that's why we set `controls`, `role` and `selected` attributes
|
||||
* only when not on mobile, they are useful only for the tab behavior.
|
||||
*/
|
||||
return (
|
||||
<ContextMenuItem
|
||||
accessibilityLabel = { label }
|
||||
className = { cx(isMobile && classes.menuItemMobile) }
|
||||
controls = { isMobile ? undefined : `dialogtab-content-${tab.name}` }
|
||||
icon = { tab.icon }
|
||||
id = { `dialogtab-button-${tab.name}` }
|
||||
key = { tab.name }
|
||||
onClick = { onClick(tab.name) }
|
||||
onKeyDown = { isMobile ? onMobileKeyDown(tab.name) : onTabKeyDown(index) }
|
||||
role = { isMobile ? undefined : 'tab' }
|
||||
selected = { tab.name === selectedTab }
|
||||
text = { label } />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(!isMobile || selectedTab) && (
|
||||
<div
|
||||
className = { classes.contentContainer }
|
||||
tabIndex = { isMobile ? -1 : undefined }>
|
||||
{/* DOM order is important for keyboard users: show whole heading first when on mobile… */}
|
||||
{isMobile && (
|
||||
<div className = { cx(classes.buttonContainer, classes.header) }>
|
||||
<span className = { classes.backContainer }>
|
||||
<h1
|
||||
className = { classes.title }
|
||||
tabIndex = { -1 }>
|
||||
{(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)}
|
||||
</h1>
|
||||
<ClickableIcon
|
||||
accessibilityLabel = { t('dialog.Back') }
|
||||
icon = { IconArrowBack }
|
||||
id = 'modal-header-back-button'
|
||||
onClick = { back } />
|
||||
</span>
|
||||
{closeIcon}
|
||||
</div>
|
||||
)}
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
aria-labelledby = { isMobile ? undefined : `${tab.name}-button` }
|
||||
className = { cx(classes.content, tab.name !== selectedTab && 'hide') }
|
||||
id = { `dialogtab-content-${tab.name}` }
|
||||
key = { tab.name }
|
||||
role = { isMobile ? undefined : 'tabpanel' }
|
||||
tabIndex = { isMobile ? -1 : 0 }>
|
||||
{ tab.name === selectedTab && selectedTabComponent }
|
||||
</div>
|
||||
))}
|
||||
{/* But show the close button *after* tab panels when not on mobile (using tabs).
|
||||
This is so that we can tab back and forth tab buttons and tab panels easily. */}
|
||||
{!isMobile && (
|
||||
<div className = { cx(classes.buttonContainer, classes.header) }>
|
||||
{closeIcon}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className = { cx(classes.buttonContainer, classes.footer) }>
|
||||
<Button
|
||||
accessibilityLabel = { t('dialog.accessibilityLabel.Cancel') }
|
||||
id = 'modal-dialog-cancel-button'
|
||||
labelKey = { 'dialog.Cancel' }
|
||||
onClick = { onClose }
|
||||
type = 'tertiary' />
|
||||
<Button
|
||||
accessibilityLabel = { t('dialog.accessibilityLabel.Ok') }
|
||||
id = 'modal-dialog-ok-button'
|
||||
labelKey = { 'dialog.Ok' }
|
||||
onClick = { onSubmit } />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogWithTabs;
|
||||
29
react/features/base/ui/components/web/HiddenDescription.tsx
Normal file
29
react/features/base/ui/components/web/HiddenDescription.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
interface IHiddenDescriptionProps {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const HiddenDescription: React.FC<IHiddenDescriptionProps> = ({ id, children }) => {
|
||||
const hiddenStyle: React.CSSProperties = {
|
||||
border: 0,
|
||||
clip: 'rect(0 0 0 0)',
|
||||
clipPath: 'inset(50%)',
|
||||
height: '1px',
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
whiteSpace: 'nowrap'
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
id = { id }
|
||||
style = { hiddenStyle }>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
290
react/features/base/ui/components/web/Input.tsx
Normal file
290
react/features/base/ui/components/web/Input.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { isMobileBrowser } from '../../../environment/utils';
|
||||
import Icon from '../../../icons/components/Icon';
|
||||
import { IconCloseCircle } from '../../../icons/svg';
|
||||
import { IInputProps } from '../types';
|
||||
|
||||
import { HiddenDescription } from './HiddenDescription';
|
||||
|
||||
interface IProps extends IInputProps {
|
||||
accessibilityLabel?: string;
|
||||
autoComplete?: string;
|
||||
autoFocus?: boolean;
|
||||
bottomLabel?: string;
|
||||
className?: string;
|
||||
hiddenDescription?: string; // Text that will be announced by screen readers but not displayed visually.
|
||||
iconClick?: () => void;
|
||||
|
||||
/**
|
||||
* The id to set on the input element.
|
||||
* This is required because we need it internally to tie the input to its
|
||||
* info (label, error) so that screen reader users don't get lost.
|
||||
*/
|
||||
id: string;
|
||||
maxLength?: number;
|
||||
maxRows?: number;
|
||||
maxValue?: number;
|
||||
minRows?: number;
|
||||
minValue?: number;
|
||||
mode?: 'text' | 'none' | 'decimal' | 'numeric' | 'tel' | 'search' | ' email' | 'url';
|
||||
name?: string;
|
||||
onBlur?: (e: any) => void;
|
||||
onFocus?: (event: React.FocusEvent) => void;
|
||||
onKeyPress?: (e: React.KeyboardEvent) => void;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
testId?: string;
|
||||
textarea?: boolean;
|
||||
type?: 'text' | 'email' | 'number' | 'password';
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
inputContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
|
||||
label: {
|
||||
color: theme.palette.text01,
|
||||
...theme.typography.bodyShortRegular,
|
||||
marginBottom: theme.spacing(2),
|
||||
|
||||
'&.is-mobile': {
|
||||
...theme.typography.bodyShortRegularLarge
|
||||
}
|
||||
},
|
||||
|
||||
fieldContainer: {
|
||||
position: 'relative',
|
||||
display: 'flex'
|
||||
},
|
||||
|
||||
input: {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
background: theme.palette.ui03,
|
||||
color: theme.palette.text01,
|
||||
...theme.typography.bodyShortRegular,
|
||||
padding: '10px 16px',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
border: 0,
|
||||
height: '40px',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
|
||||
'&::placeholder': {
|
||||
color: theme.palette.text02
|
||||
},
|
||||
|
||||
'&:focus': {
|
||||
outline: 0,
|
||||
boxShadow: `0px 0px 0px 2px ${theme.palette.focus01}`
|
||||
},
|
||||
|
||||
'&:disabled': {
|
||||
color: theme.palette.text03
|
||||
},
|
||||
|
||||
'&.is-mobile': {
|
||||
height: '48px',
|
||||
padding: '13px 16px',
|
||||
...theme.typography.bodyShortRegularLarge
|
||||
},
|
||||
|
||||
'&.icon-input': {
|
||||
paddingLeft: '46px'
|
||||
},
|
||||
|
||||
'&.error': {
|
||||
boxShadow: `0px 0px 0px 2px ${theme.palette.textError}`
|
||||
},
|
||||
'&.clearable-input': {
|
||||
paddingRight: '46px'
|
||||
}
|
||||
},
|
||||
|
||||
'input::-webkit-outer-spin-button, input::-webkit-inner-spin-button': {
|
||||
'-webkit-appearance': 'none',
|
||||
margin: 0
|
||||
},
|
||||
|
||||
'input[type=number]': {
|
||||
'-moz-appearance': 'textfield'
|
||||
},
|
||||
|
||||
icon: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
left: '16px'
|
||||
},
|
||||
|
||||
iconClickable: {
|
||||
cursor: 'pointer'
|
||||
},
|
||||
|
||||
clearButton: {
|
||||
position: 'absolute',
|
||||
right: '16px',
|
||||
top: '10px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: theme.palette.action03,
|
||||
border: 0,
|
||||
padding: 0
|
||||
},
|
||||
|
||||
bottomLabel: {
|
||||
marginTop: theme.spacing(2),
|
||||
...theme.typography.labelRegular,
|
||||
color: theme.palette.text02,
|
||||
|
||||
'&.is-mobile': {
|
||||
...theme.typography.bodyShortRegular
|
||||
},
|
||||
|
||||
'&.error': {
|
||||
color: theme.palette.textError
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const Input = React.forwardRef<any, IProps>(({
|
||||
accessibilityLabel,
|
||||
autoComplete = 'off',
|
||||
autoFocus,
|
||||
bottomLabel,
|
||||
className,
|
||||
clearable = false,
|
||||
disabled,
|
||||
error,
|
||||
hiddenDescription,
|
||||
icon,
|
||||
iconClick,
|
||||
id,
|
||||
label,
|
||||
maxValue,
|
||||
maxLength,
|
||||
maxRows,
|
||||
minValue,
|
||||
minRows,
|
||||
mode,
|
||||
name,
|
||||
onBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
onKeyPress,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
required,
|
||||
testId,
|
||||
textarea = false,
|
||||
type = 'text',
|
||||
value
|
||||
}: IProps, ref) => {
|
||||
const { classes: styles, cx } = useStyles();
|
||||
const isMobile = isMobileBrowser();
|
||||
const showClearIcon = clearable && value !== '' && !disabled;
|
||||
const inputAutoCompleteOff = autoComplete === 'off' ? { 'data-1p-ignore': '' } : {};
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
onChange?.(e.target.value), []);
|
||||
|
||||
const clearInput = useCallback(() => onChange?.(''), []);
|
||||
const hiddenDescriptionId = `${id}-hidden-description`;
|
||||
let ariaDescribedById: string | undefined;
|
||||
|
||||
if (bottomLabel) {
|
||||
ariaDescribedById = `${id}-description`;
|
||||
} else if (hiddenDescription) {
|
||||
ariaDescribedById = hiddenDescriptionId;
|
||||
} else {
|
||||
ariaDescribedById = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { cx(styles.inputContainer, className) }>
|
||||
{label && <label
|
||||
className = { cx(styles.label, isMobile && 'is-mobile') }
|
||||
htmlFor = { id } >
|
||||
{label}
|
||||
</label>}
|
||||
<div className = { styles.fieldContainer }>
|
||||
{icon && <Icon
|
||||
{ ...(iconClick ? { tabIndex: 0 } : {}) }
|
||||
className = { cx(styles.icon, iconClick && styles.iconClickable) }
|
||||
onClick = { iconClick }
|
||||
size = { 20 }
|
||||
src = { icon } />}
|
||||
{textarea ? (
|
||||
<TextareaAutosize
|
||||
aria-describedby = { ariaDescribedById }
|
||||
aria-label = { accessibilityLabel }
|
||||
autoComplete = { autoComplete }
|
||||
autoFocus = { autoFocus }
|
||||
className = { cx(styles.input, isMobile && 'is-mobile',
|
||||
error && 'error', showClearIcon && 'clearable-input', icon && 'icon-input') }
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
maxLength = { maxLength }
|
||||
maxRows = { maxRows }
|
||||
minRows = { minRows }
|
||||
name = { name }
|
||||
onChange = { handleChange }
|
||||
onKeyPress = { onKeyPress }
|
||||
placeholder = { placeholder }
|
||||
readOnly = { readOnly }
|
||||
ref = { ref }
|
||||
required = { required }
|
||||
value = { value } />
|
||||
) : (
|
||||
<input
|
||||
aria-describedby = { ariaDescribedById }
|
||||
aria-label = { accessibilityLabel }
|
||||
autoComplete = { autoComplete }
|
||||
autoFocus = { autoFocus }
|
||||
className = { cx(styles.input, isMobile && 'is-mobile',
|
||||
error && 'error', showClearIcon && 'clearable-input', icon && 'icon-input') }
|
||||
data-testid = { testId }
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
{ ...inputAutoCompleteOff }
|
||||
{ ...(mode ? { inputmode: mode } : {}) }
|
||||
{ ...(type === 'number' ? { max: maxValue } : {}) }
|
||||
maxLength = { maxLength }
|
||||
{ ...(type === 'number' ? { min: minValue } : {}) }
|
||||
name = { name }
|
||||
onBlur = { onBlur }
|
||||
onChange = { handleChange }
|
||||
onFocus = { onFocus }
|
||||
onKeyPress = { onKeyPress }
|
||||
placeholder = { placeholder }
|
||||
readOnly = { readOnly }
|
||||
ref = { ref }
|
||||
required = { required }
|
||||
type = { type }
|
||||
value = { value } />
|
||||
)}
|
||||
{showClearIcon && <button className = { styles.clearButton }>
|
||||
<Icon
|
||||
onClick = { clearInput }
|
||||
size = { 20 }
|
||||
src = { IconCloseCircle } />
|
||||
</button>}
|
||||
</div>
|
||||
{bottomLabel && (
|
||||
<span
|
||||
className = { cx(styles.bottomLabel, isMobile && 'is-mobile', error && 'error') }
|
||||
id = { `${id}-description` }>
|
||||
{bottomLabel}
|
||||
</span>
|
||||
)}
|
||||
{!bottomLabel && hiddenDescription && <HiddenDescription id = { hiddenDescriptionId }>{ hiddenDescription }</HiddenDescription>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Input;
|
||||
294
react/features/base/ui/components/web/ListItem.tsx
Normal file
294
react/features/base/ui/components/web/ListItem.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { ACTION_TRIGGER } from '../../../../participants-pane/constants';
|
||||
import participantsPaneTheme from '../../../components/themes/participantsPaneTheme.json';
|
||||
import { isMobileBrowser } from '../../../environment/utils';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* List item actions.
|
||||
*/
|
||||
actions: ReactNode;
|
||||
|
||||
/**
|
||||
* List item container class name.
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* The breakout name for aria-label.
|
||||
*/
|
||||
defaultName?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the actions should be hidden.
|
||||
*/
|
||||
hideActions?: boolean;
|
||||
|
||||
/**
|
||||
* Icon to be displayed on the list item. (Avatar for participants).
|
||||
*/
|
||||
icon: ReactNode;
|
||||
|
||||
/**
|
||||
* Id of the container.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Indicators to be displayed on the list item.
|
||||
*/
|
||||
indicators?: ReactNode;
|
||||
|
||||
/**
|
||||
* Whether or not the item is highlighted.
|
||||
*/
|
||||
isHighlighted?: boolean;
|
||||
|
||||
/**
|
||||
* Click handler.
|
||||
*/
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Long press handler.
|
||||
*/
|
||||
onLongPress?: (e?: EventTarget) => void;
|
||||
|
||||
/**
|
||||
* Mouse leave handler.
|
||||
*/
|
||||
onMouseLeave?: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Data test id.
|
||||
*/
|
||||
testId?: string;
|
||||
|
||||
/**
|
||||
* Text children to be displayed on the list item.
|
||||
*/
|
||||
textChildren: ReactNode | string;
|
||||
|
||||
/**
|
||||
* The actions trigger. Can be Hover or Permanent.
|
||||
*/
|
||||
trigger: string;
|
||||
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
color: theme.palette.text01,
|
||||
display: 'flex',
|
||||
...theme.typography.bodyShortBold,
|
||||
margin: `0 -${participantsPaneTheme.panePadding}px`,
|
||||
padding: `${theme.spacing(2)} ${participantsPaneTheme.panePadding}px`,
|
||||
position: 'relative',
|
||||
boxShadow: 'inset 0px -1px 0px rgba(255, 255, 255, 0.15)',
|
||||
minHeight: '40px',
|
||||
|
||||
'&:hover, &:focus-within': {
|
||||
backgroundColor: theme.palette.ui02,
|
||||
|
||||
'& .indicators': {
|
||||
display: 'none'
|
||||
},
|
||||
|
||||
'& .actions': {
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
top: 'auto',
|
||||
boxShadow: `-15px 0px 10px -5px ${theme.palette.ui02}`,
|
||||
backgroundColor: theme.palette.ui02
|
||||
}
|
||||
},
|
||||
|
||||
[`@media(max-width: ${participantsPaneTheme.MD_BREAKPOINT})`]: {
|
||||
...theme.typography.bodyShortBoldLarge,
|
||||
padding: `${theme.spacing(3)} ${participantsPaneTheme.panePadding}px`
|
||||
}
|
||||
},
|
||||
|
||||
highlighted: {
|
||||
backgroundColor: theme.palette.ui02,
|
||||
|
||||
'& .actions': {
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
top: 'auto',
|
||||
boxShadow: `-15px 0px 10px -5px ${theme.palette.ui02}`,
|
||||
backgroundColor: theme.palette.ui02
|
||||
}
|
||||
},
|
||||
|
||||
detailsContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
},
|
||||
|
||||
name: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
marginRight: theme.spacing(2),
|
||||
overflow: 'hidden',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start'
|
||||
},
|
||||
|
||||
indicators: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
|
||||
'& > *': {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
'& > *:not(:last-child)': {
|
||||
marginRight: theme.spacing(2)
|
||||
},
|
||||
|
||||
'& .jitsi-icon': {
|
||||
padding: '3px'
|
||||
}
|
||||
},
|
||||
|
||||
indicatorsHidden: {
|
||||
display: 'none'
|
||||
},
|
||||
|
||||
actionsContainer: {
|
||||
position: 'absolute',
|
||||
top: '-1000px',
|
||||
boxShadow: `-15px 0px 10px -5px ${theme.palette.ui02}`,
|
||||
backgroundColor: theme.palette.ui02
|
||||
},
|
||||
|
||||
actionsPermanent: {
|
||||
display: 'flex',
|
||||
boxShadow: `-15px 0px 10px -5px ${theme.palette.ui01}`,
|
||||
backgroundColor: theme.palette.ui01
|
||||
},
|
||||
|
||||
actionsVisible: {
|
||||
display: 'flex',
|
||||
boxShadow: `-15px 0px 10px -5px ${theme.palette.ui02}`,
|
||||
backgroundColor: theme.palette.ui02
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ListItem = ({
|
||||
actions,
|
||||
className,
|
||||
defaultName,
|
||||
icon,
|
||||
id,
|
||||
hideActions = false,
|
||||
indicators,
|
||||
isHighlighted,
|
||||
onClick,
|
||||
onLongPress,
|
||||
onMouseLeave,
|
||||
testId,
|
||||
textChildren,
|
||||
trigger
|
||||
}: IProps) => {
|
||||
const { classes, cx } = useStyles();
|
||||
const isMobile = isMobileBrowser();
|
||||
let timeoutHandler: number;
|
||||
|
||||
/**
|
||||
* Set calling long press handler after x milliseconds.
|
||||
*
|
||||
* @param {TouchEvent} e - Touch start event.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onTouchStart(e: React.TouchEvent) {
|
||||
const target = e.touches[0].target;
|
||||
|
||||
timeoutHandler = window.setTimeout(() => onLongPress?.(target), 600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel calling on long press after x milliseconds if the number of milliseconds is not reached
|
||||
* before a touch move(drag), or just clears the timeout.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onTouchMove() {
|
||||
clearTimeout(timeoutHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel calling on long press after x milliseconds if the number of milliseconds is not reached yet,
|
||||
* or just clears the timeout.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onTouchEnd() {
|
||||
clearTimeout(timeoutHandler);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label = { defaultName }
|
||||
className = { cx('list-item-container',
|
||||
classes.container,
|
||||
isHighlighted && classes.highlighted,
|
||||
className
|
||||
) }
|
||||
data-testid = { testId }
|
||||
id = { id }
|
||||
onClick = { onClick }
|
||||
role = 'listitem'
|
||||
{ ...(isMobile
|
||||
? {
|
||||
onTouchEnd: _onTouchEnd,
|
||||
onTouchMove: _onTouchMove,
|
||||
onTouchStart: _onTouchStart
|
||||
}
|
||||
: {
|
||||
onMouseLeave
|
||||
}
|
||||
) }>
|
||||
<div> {icon} </div>
|
||||
<div className = { classes.detailsContainer }>
|
||||
<div className = { classes.name }>
|
||||
{textChildren}
|
||||
</div>
|
||||
{indicators && (
|
||||
<div
|
||||
className = { cx('indicators',
|
||||
classes.indicators,
|
||||
(isHighlighted || trigger === ACTION_TRIGGER.PERMANENT) && classes.indicatorsHidden
|
||||
) }>
|
||||
{indicators}
|
||||
</div>
|
||||
)}
|
||||
{!hideActions && (
|
||||
<div
|
||||
className = { cx('actions',
|
||||
classes.actionsContainer,
|
||||
trigger === ACTION_TRIGGER.PERMANENT && classes.actionsPermanent,
|
||||
isHighlighted && classes.actionsVisible
|
||||
) }>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItem;
|
||||
179
react/features/base/ui/components/web/MultiSelect.tsx
Normal file
179
react/features/base/ui/components/web/MultiSelect.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IconCloseLarge } from '../../../icons/svg';
|
||||
import { MultiSelectItem } from '../types';
|
||||
|
||||
import ClickableIcon from './ClickableIcon';
|
||||
import Input from './Input';
|
||||
|
||||
interface IProps {
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
errorDialog?: JSX.Element | null;
|
||||
filterValue?: string;
|
||||
id: string;
|
||||
isOpen?: boolean;
|
||||
items: MultiSelectItem[];
|
||||
noMatchesText?: string;
|
||||
onFilterChange?: (value: string) => void;
|
||||
onRemoved: (item: any) => void;
|
||||
onSelected: (item: any) => void;
|
||||
placeholder?: string;
|
||||
selectedItems?: MultiSelectItem[];
|
||||
}
|
||||
|
||||
const MULTI_SELECT_HEIGHT = 200;
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
position: 'relative'
|
||||
},
|
||||
items: {
|
||||
'&.found': {
|
||||
position: 'absolute',
|
||||
boxShadow: '0px 5px 10px rgba(0, 0, 0, 0.75)'
|
||||
},
|
||||
marginTop: theme.spacing(2),
|
||||
width: '100%',
|
||||
backgroundColor: theme.palette.ui01,
|
||||
border: `1px solid ${theme.palette.ui04}`,
|
||||
borderRadius: `${Number(theme.shape.borderRadius)}px`,
|
||||
...theme.typography.bodyShortRegular,
|
||||
zIndex: 2,
|
||||
maxHeight: `${MULTI_SELECT_HEIGHT}px`,
|
||||
overflowY: 'auto',
|
||||
padding: '0'
|
||||
},
|
||||
listItem: {
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
padding: `${theme.spacing(2)} ${theme.spacing(3)}`,
|
||||
alignItems: 'center',
|
||||
'& .content': {
|
||||
// 38px because of the icon before the content
|
||||
inlineSize: 'calc(100% - 38px)',
|
||||
overflowWrap: 'break-word',
|
||||
marginLeft: theme.spacing(2),
|
||||
color: theme.palette.text01,
|
||||
'&.with-remove': {
|
||||
// 60px because of the icon before the content and the remove button
|
||||
inlineSize: 'calc(100% - 60px)',
|
||||
marginRight: theme.spacing(2),
|
||||
'&.without-before': {
|
||||
marginLeft: 0,
|
||||
inlineSize: 'calc(100% - 38px)'
|
||||
}
|
||||
},
|
||||
'&.without-before': {
|
||||
marginLeft: 0,
|
||||
inlineSize: '100%'
|
||||
}
|
||||
},
|
||||
'&.found': {
|
||||
cursor: 'pointer',
|
||||
padding: `10px ${theme.spacing(3)}`,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.ui02
|
||||
}
|
||||
},
|
||||
'&.disabled': {
|
||||
cursor: 'not-allowed',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.ui01
|
||||
},
|
||||
color: theme.palette.text03
|
||||
}
|
||||
},
|
||||
errorMessage: {
|
||||
position: 'absolute',
|
||||
marginTop: theme.spacing(2),
|
||||
width: '100%'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const MultiSelect = ({
|
||||
autoFocus,
|
||||
disabled,
|
||||
error,
|
||||
errorDialog,
|
||||
placeholder,
|
||||
id,
|
||||
items,
|
||||
filterValue,
|
||||
onFilterChange,
|
||||
isOpen,
|
||||
noMatchesText,
|
||||
onSelected,
|
||||
selectedItems,
|
||||
onRemoved
|
||||
}: IProps) => {
|
||||
const { classes } = useStyles();
|
||||
const inputRef = useRef();
|
||||
const selectItem = useCallback(item => () => onSelected(item), [ onSelected ]);
|
||||
const removeItem = useCallback(item => () => onRemoved(item), [ onRemoved ]);
|
||||
const foundItems = useMemo(() => (
|
||||
<div className = { `${classes.items} found` }>
|
||||
{
|
||||
items.length > 0
|
||||
? items.map(item => (
|
||||
<div
|
||||
className = { `${classes.listItem} ${item.isDisabled ? 'disabled' : ''} found` }
|
||||
key = { item.value }
|
||||
onClick = { item.isDisabled ? undefined : selectItem(item) }>
|
||||
{item.elemBefore}
|
||||
<div className = { `content ${item.elemBefore ? '' : 'without-before'}` }>
|
||||
{item.content}
|
||||
{item.description && <p>{item.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: <div className = { classes.listItem }>{noMatchesText}</div>
|
||||
}
|
||||
</div>
|
||||
), [ items ]);
|
||||
|
||||
const errorMessageDialog = useMemo(() =>
|
||||
error && <div className = { classes.errorMessage }>
|
||||
{ errorDialog }
|
||||
</div>, [ error ]);
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<Input
|
||||
autoFocus = { autoFocus }
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
onChange = { onFilterChange }
|
||||
placeholder = { placeholder }
|
||||
ref = { inputRef }
|
||||
value = { filterValue ?? '' } />
|
||||
{isOpen && foundItems}
|
||||
{ errorMessageDialog }
|
||||
{ selectedItems && selectedItems?.length > 0 && (
|
||||
<div className = { classes.items }>
|
||||
{ selectedItems.map(item => (
|
||||
<div
|
||||
className = { `${classes.listItem} ${item.isDisabled ? 'disabled' : ''}` }
|
||||
key = { item.value }>
|
||||
{item.elemBefore}
|
||||
<div className = { `content with-remove ${item.elemBefore ? '' : 'without-before'}` }>
|
||||
<p>{item.content}</p>
|
||||
</div>
|
||||
<ClickableIcon
|
||||
accessibilityLabel = { 'multi-select-unselect' }
|
||||
icon = { IconCloseLarge }
|
||||
id = 'modal-header-close-button'
|
||||
onClick = { removeItem(item) } />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiSelect;
|
||||
198
react/features/base/ui/components/web/Select.tsx
Normal file
198
react/features/base/ui/components/web/Select.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { isMobileBrowser } from '../../../environment/utils';
|
||||
import Icon from '../../../icons/components/Icon';
|
||||
import { IconArrowDown } from '../../../icons/svg';
|
||||
|
||||
interface ISelectProps {
|
||||
|
||||
/**
|
||||
* Helper text to be displayed below the select.
|
||||
*/
|
||||
bottomLabel?: string;
|
||||
|
||||
/**
|
||||
* Class name for additional styles.
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* Class name for additional styles for container.
|
||||
*/
|
||||
containerClassName?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the select is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the select is in the error state.
|
||||
*/
|
||||
error?: boolean;
|
||||
|
||||
/**
|
||||
* Id of the <select> element.
|
||||
* Necessary for screen reader users, to link the label and error to the select.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Label to be displayed above the select.
|
||||
*/
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* Change handler.
|
||||
*/
|
||||
onChange: (e: ChangeEvent<HTMLSelectElement>) => void;
|
||||
|
||||
/**
|
||||
* The options of the select.
|
||||
*/
|
||||
options: Array<{
|
||||
label: string;
|
||||
value: number | string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The value of the select.
|
||||
*/
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
|
||||
label: {
|
||||
color: theme.palette.text01,
|
||||
...theme.typography.bodyShortRegular,
|
||||
marginBottom: theme.spacing(2),
|
||||
|
||||
'&.is-mobile': {
|
||||
...theme.typography.bodyShortRegularLarge
|
||||
}
|
||||
},
|
||||
|
||||
selectContainer: {
|
||||
position: 'relative'
|
||||
},
|
||||
|
||||
select: {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
width: '100%',
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text01,
|
||||
padding: '10px 16px',
|
||||
paddingRight: '42px',
|
||||
border: 0,
|
||||
appearance: 'none',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
|
||||
'&:focus': {
|
||||
outline: 0,
|
||||
boxShadow: `0px 0px 0px 2px ${theme.palette.focus01}`
|
||||
},
|
||||
|
||||
'&:disabled': {
|
||||
color: theme.palette.text03
|
||||
},
|
||||
|
||||
'&.is-mobile': {
|
||||
...theme.typography.bodyShortRegularLarge,
|
||||
padding: '12px 16px',
|
||||
paddingRight: '46px'
|
||||
},
|
||||
|
||||
'&.error': {
|
||||
boxShadow: `0px 0px 0px 2px ${theme.palette.textError}`
|
||||
}
|
||||
},
|
||||
|
||||
icon: {
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
pointerEvents: 'none',
|
||||
|
||||
'&.is-mobile': {
|
||||
top: '12px',
|
||||
right: '12px'
|
||||
}
|
||||
},
|
||||
|
||||
bottomLabel: {
|
||||
marginTop: theme.spacing(2),
|
||||
...theme.typography.labelRegular,
|
||||
color: theme.palette.text02,
|
||||
|
||||
'&.is-mobile': {
|
||||
...theme.typography.bodyShortRegular
|
||||
},
|
||||
|
||||
'&.error': {
|
||||
color: theme.palette.textError
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const Select = ({
|
||||
bottomLabel,
|
||||
containerClassName,
|
||||
className,
|
||||
disabled,
|
||||
error,
|
||||
id,
|
||||
label,
|
||||
onChange,
|
||||
options,
|
||||
value }: ISelectProps) => {
|
||||
const { classes, cx, theme } = useStyles();
|
||||
const isMobile = isMobileBrowser();
|
||||
|
||||
return (
|
||||
<div className = { cx(classes.container, containerClassName) }>
|
||||
{label && <label
|
||||
className = { cx(classes.label, isMobile && 'is-mobile') }
|
||||
htmlFor = { id } >
|
||||
{label}
|
||||
</label>}
|
||||
<div className = { classes.selectContainer }>
|
||||
<select
|
||||
aria-describedby = { bottomLabel ? `${id}-description` : undefined }
|
||||
className = { cx(classes.select, isMobile && 'is-mobile', className, error && 'error') }
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
onChange = { onChange }
|
||||
value = { value }>
|
||||
{options.map(option => (<option
|
||||
key = { option.value }
|
||||
value = { option.value }>{option.label}</option>))}
|
||||
</select>
|
||||
<Icon
|
||||
className = { cx(classes.icon, isMobile && 'is-mobile') }
|
||||
color = { disabled ? theme.palette.icon03 : theme.palette.icon01 }
|
||||
size = { 22 }
|
||||
src = { IconArrowDown } />
|
||||
</div>
|
||||
{bottomLabel && (
|
||||
<span
|
||||
className = { cx(classes.bottomLabel, isMobile && 'is-mobile', error && 'error') }
|
||||
id = { `${id}-description` }>
|
||||
{bottomLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Select;
|
||||
79
react/features/base/ui/components/web/Spinner.tsx
Normal file
79
react/features/base/ui/components/web/Spinner.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { keyframes } from 'tss-react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
interface IProps {
|
||||
color?: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
const SIZE = {
|
||||
small: 16,
|
||||
medium: 24,
|
||||
large: 48
|
||||
};
|
||||
|
||||
const DEFAULT_COLOR = '#E6EDFA';
|
||||
|
||||
const useStyles = makeStyles<{ color?: string; }>()((_, { color }) => {
|
||||
return {
|
||||
container: {
|
||||
verticalAlign: 'middle',
|
||||
opacity: 0,
|
||||
animation: `${keyframes`
|
||||
0% {
|
||||
transform: rotate(50deg);
|
||||
opacity: 0;
|
||||
stroke-dashoffset: 60;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(230deg);
|
||||
opacity: 1;
|
||||
stroke-dashoffset: 50;
|
||||
}
|
||||
`} 1s forwards ease-in-out`
|
||||
},
|
||||
|
||||
circle: {
|
||||
fill: 'none',
|
||||
stroke: color,
|
||||
strokeWidth: 1.5,
|
||||
strokeLinecap: 'round',
|
||||
strokeDasharray: 60,
|
||||
strokeDashoffset: 'inherit',
|
||||
transformOrigin: 'center',
|
||||
animation: `${keyframes`
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
`} 0.86s forwards infinite`,
|
||||
animationDelay: '0ms',
|
||||
animationTimingFunction: 'cubic-bezier(0.4, 0.15, 0.6, 0.85)'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const Spinner = ({ color = DEFAULT_COLOR, size = 'medium' }: IProps) => {
|
||||
const { classes } = useStyles({ color });
|
||||
|
||||
return (
|
||||
<svg
|
||||
className = { classes.container }
|
||||
focusable = 'false'
|
||||
height = { SIZE[size] }
|
||||
viewBox = '0 0 16 16'
|
||||
width = { SIZE[size] }
|
||||
xmlns = 'http://www.w3.org/2000/svg'>
|
||||
<circle
|
||||
className = { classes.circle }
|
||||
cx = '8'
|
||||
cy = '8'
|
||||
r = '7' />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
138
react/features/base/ui/components/web/Switch.tsx
Normal file
138
react/features/base/ui/components/web/Switch.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { isMobileBrowser } from '../../../environment/utils';
|
||||
import { ISwitchProps } from '../types';
|
||||
|
||||
interface IProps extends ISwitchProps {
|
||||
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* Id of the toggle.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
position: 'relative',
|
||||
backgroundColor: theme.palette.ui05,
|
||||
borderRadius: '12px',
|
||||
width: '40px',
|
||||
height: '24px',
|
||||
border: 0,
|
||||
outline: 0,
|
||||
cursor: 'pointer',
|
||||
transition: '.3s',
|
||||
display: 'inline-block',
|
||||
|
||||
'&.disabled': {
|
||||
backgroundColor: theme.palette.ui05,
|
||||
cursor: 'default',
|
||||
|
||||
'& .toggle': {
|
||||
backgroundColor: theme.palette.ui03
|
||||
}
|
||||
},
|
||||
|
||||
'&.is-mobile': {
|
||||
height: '32px',
|
||||
width: '50px',
|
||||
borderRadius: '32px'
|
||||
}
|
||||
},
|
||||
|
||||
containerOn: {
|
||||
backgroundColor: theme.palette.action01
|
||||
},
|
||||
|
||||
toggle: {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
position: 'absolute',
|
||||
zIndex: 5,
|
||||
top: '4px',
|
||||
left: '4px',
|
||||
backgroundColor: theme.palette.ui10,
|
||||
borderRadius: '100%',
|
||||
transition: '.3s',
|
||||
|
||||
'&.is-mobile': {
|
||||
width: '24px',
|
||||
height: '24px'
|
||||
}
|
||||
},
|
||||
|
||||
toggleOn: {
|
||||
left: '20px',
|
||||
|
||||
'&.is-mobile': {
|
||||
left: '22px'
|
||||
}
|
||||
},
|
||||
|
||||
checkbox: {
|
||||
position: 'absolute',
|
||||
zIndex: 10,
|
||||
cursor: 'pointer',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: 0,
|
||||
|
||||
'&.focus-visible + .toggle-checkbox-ring': {
|
||||
outline: 0,
|
||||
boxShadow: `0px 0px 0px 2px ${theme.palette.focus01}`
|
||||
}
|
||||
},
|
||||
|
||||
checkboxRing: {
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 6,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '12px',
|
||||
|
||||
'&.is-mobile': {
|
||||
borderRadius: '32px'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const Switch = ({ className, id, checked, disabled, onChange }: IProps) => {
|
||||
const { classes: styles, cx } = useStyles();
|
||||
const isMobile = isMobileBrowser();
|
||||
|
||||
const change = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.checked);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span
|
||||
className = { cx('toggle-container', styles.container, checked && styles.containerOn,
|
||||
isMobile && 'is-mobile', disabled && 'disabled', className) }>
|
||||
<input
|
||||
type = 'checkbox'
|
||||
{ ...(id ? { id } : {}) }
|
||||
checked = { checked }
|
||||
className = { styles.checkbox }
|
||||
disabled = { disabled }
|
||||
onChange = { change } />
|
||||
<div className = { cx('toggle-checkbox-ring', styles.checkboxRing, isMobile && 'is-mobile') } />
|
||||
<div className = { cx('toggle', styles.toggle, checked && styles.toggleOn, isMobile && 'is-mobile') } />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Switch;
|
||||
162
react/features/base/ui/components/web/Tabs.tsx
Normal file
162
react/features/base/ui/components/web/Tabs.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { isMobileBrowser } from '../../../environment/utils';
|
||||
import Icon from '../../../icons/components/Icon';
|
||||
|
||||
interface ITabProps {
|
||||
accessibilityLabel: string;
|
||||
className?: string;
|
||||
onChange: (id: string) => void;
|
||||
selected: string;
|
||||
tabs: Array<{
|
||||
accessibilityLabel: string;
|
||||
controlsId: string;
|
||||
countBadge?: number;
|
||||
disabled?: boolean;
|
||||
icon?: Function;
|
||||
id: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex'
|
||||
},
|
||||
|
||||
tab: {
|
||||
...theme.typography.bodyShortBold,
|
||||
color: theme.palette.text02,
|
||||
flex: 1,
|
||||
padding: '14px',
|
||||
background: 'none',
|
||||
border: 0,
|
||||
appearance: 'none',
|
||||
borderBottom: `2px solid ${theme.palette.ui05}`,
|
||||
transition: 'color, border-color 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 0,
|
||||
|
||||
'&:hover': {
|
||||
color: theme.palette.text01,
|
||||
borderColor: theme.palette.ui10
|
||||
},
|
||||
|
||||
'&.focus-visible': {
|
||||
outline: 0,
|
||||
boxShadow: `0px 0px 0px 2px ${theme.palette.focus01}`,
|
||||
border: 0,
|
||||
color: theme.palette.text01
|
||||
},
|
||||
|
||||
'&.selected': {
|
||||
color: theme.palette.text01,
|
||||
borderColor: theme.palette.action01
|
||||
},
|
||||
|
||||
'&:disabled': {
|
||||
color: theme.palette.text03,
|
||||
borderColor: theme.palette.ui05
|
||||
},
|
||||
|
||||
'&.is-mobile': {
|
||||
...theme.typography.bodyShortBoldLarge
|
||||
}
|
||||
},
|
||||
|
||||
badge: {
|
||||
...theme.typography.labelBold,
|
||||
color: theme.palette.text04,
|
||||
padding: `0 ${theme.spacing(1)}`,
|
||||
borderRadius: '100%',
|
||||
backgroundColor: theme.palette.warning01,
|
||||
marginLeft: theme.spacing(2)
|
||||
},
|
||||
|
||||
icon: {
|
||||
marginRight: theme.spacing(1)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
const Tabs = ({
|
||||
accessibilityLabel,
|
||||
className,
|
||||
onChange,
|
||||
selected,
|
||||
tabs
|
||||
}: ITabProps) => {
|
||||
const { classes, cx } = useStyles();
|
||||
const isMobile = isMobileBrowser();
|
||||
const onClick = useCallback(id => () => {
|
||||
onChange(id);
|
||||
}, []);
|
||||
const onKeyDown = useCallback((index: number) => (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
let newIndex: number | null = null;
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
newIndex = index === 0 ? tabs.length - 1 : index - 1;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
newIndex = index === tabs.length - 1 ? 0 : index + 1;
|
||||
}
|
||||
|
||||
if (newIndex !== null) {
|
||||
onChange(tabs[newIndex].id);
|
||||
}
|
||||
}, [ tabs ]);
|
||||
|
||||
useEffect(() => {
|
||||
// this test is needed to make sure the effect is triggered because of user actually changing tab
|
||||
if (document.activeElement?.getAttribute('role') === 'tab') {
|
||||
document.querySelector<HTMLButtonElement>(`#${selected}`)?.focus();
|
||||
}
|
||||
}, [ selected ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label = { accessibilityLabel }
|
||||
className = { cx(classes.container, className) }
|
||||
role = 'tablist'>
|
||||
{
|
||||
tabs.map((tab, index) => (
|
||||
<button
|
||||
aria-controls = { tab.controlsId }
|
||||
aria-label = { tab.accessibilityLabel }
|
||||
aria-selected = { selected === tab.id }
|
||||
className = { cx(classes.tab, selected === tab.id && 'selected', isMobile && 'is-mobile') }
|
||||
disabled = { tab.disabled }
|
||||
id = { tab.id }
|
||||
key = { tab.id }
|
||||
onClick = { onClick(tab.id) }
|
||||
onKeyDown = { onKeyDown(index) }
|
||||
role = 'tab'
|
||||
tabIndex = { selected === tab.id ? undefined : -1 }
|
||||
title = { tab.title }>
|
||||
{
|
||||
tab.icon && <Icon
|
||||
className = { classes.icon }
|
||||
src = { tab.icon } />
|
||||
}
|
||||
{ tab.label }
|
||||
{
|
||||
tab.countBadge && <span className = { classes.badge }>
|
||||
{ tab.countBadge }
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
74
react/features/base/ui/components/web/TextWithOverflow.tsx
Normal file
74
react/features/base/ui/components/web/TextWithOverflow.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { ReactNode, useRef } from 'react';
|
||||
import { keyframes } from 'tss-react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { TEXT_OVERFLOW_TYPES } from '../../constants.web';
|
||||
|
||||
interface ITextWithOverflowProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
overflowType?: TEXT_OVERFLOW_TYPES;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles<{ translateDiff: number; }>()((_, { translateDiff }) => {
|
||||
return {
|
||||
animation: {
|
||||
'&:hover': {
|
||||
animation: `${keyframes`
|
||||
0%, 20% {
|
||||
transform: translateX(0%);
|
||||
left: 0%;
|
||||
}
|
||||
80%, 100% {
|
||||
transform: translateX(-${translateDiff}px);
|
||||
left: 100%;
|
||||
}
|
||||
`} ${Math.max(translateDiff * 50, 2000)}ms infinite alternate linear;`
|
||||
}
|
||||
},
|
||||
textContainer: {
|
||||
overflow: 'hidden'
|
||||
},
|
||||
[TEXT_OVERFLOW_TYPES.ELLIPSIS]: {
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
[TEXT_OVERFLOW_TYPES.SCROLL_ON_HOVER]: {
|
||||
display: 'inline-block',
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const TextWithOverflow = ({
|
||||
className,
|
||||
overflowType = TEXT_OVERFLOW_TYPES.ELLIPSIS,
|
||||
children
|
||||
}: ITextWithOverflowProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLSpanElement>(null);
|
||||
const shouldAnimateOnHover = overflowType === TEXT_OVERFLOW_TYPES.SCROLL_ON_HOVER
|
||||
&& containerRef.current
|
||||
&& contentRef.current
|
||||
&& containerRef.current.clientWidth < contentRef.current.clientWidth;
|
||||
|
||||
const translateDiff = shouldAnimateOnHover ? contentRef.current.clientWidth - containerRef.current.clientWidth : 0;
|
||||
const { classes: styles, cx } = useStyles({ translateDiff });
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { cx(className, styles.textContainer) }
|
||||
ref = { containerRef }>
|
||||
<span
|
||||
className = { cx(styles[overflowType], shouldAnimateOnHover && styles.animation) }
|
||||
ref = { contentRef }>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextWithOverflow;
|
||||
31
react/features/base/ui/constants.any.ts
Normal file
31
react/features/base/ui/constants.any.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* The types of the buttons.
|
||||
*/
|
||||
export enum BUTTON_TYPES {
|
||||
DESTRUCTIVE = 'destructive',
|
||||
PRIMARY = 'primary',
|
||||
SECONDARY = 'secondary',
|
||||
TERTIARY = 'tertiary'
|
||||
}
|
||||
|
||||
/**
|
||||
* Behaviour types for showing overflow text content.
|
||||
*/
|
||||
export enum TEXT_OVERFLOW_TYPES {
|
||||
ELLIPSIS = 'ellipsis',
|
||||
SCROLL_ON_HOVER = 'scroll-on-hover'
|
||||
}
|
||||
|
||||
/**
|
||||
* The modes of the buttons.
|
||||
*/
|
||||
export const BUTTON_MODES: {
|
||||
CONTAINED: 'contained';
|
||||
TEXT: 'text';
|
||||
} = {
|
||||
CONTAINED: 'contained',
|
||||
TEXT: 'text'
|
||||
};
|
||||
|
||||
|
||||
export type TOOLTIP_POSITION = 'top' | 'bottom' | 'left' | 'right';
|
||||
1
react/features/base/ui/constants.native.ts
Normal file
1
react/features/base/ui/constants.native.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './constants.any';
|
||||
275
react/features/base/ui/constants.web.ts
Normal file
275
react/features/base/ui/constants.web.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Theme } from '@mui/material';
|
||||
|
||||
export * from './constants.any';
|
||||
|
||||
/**
|
||||
* Returns an object containing the declaration of the common, reusable CSS classes.
|
||||
*
|
||||
* @param {Object} theme -The theme.
|
||||
*
|
||||
* @returns {Object} - The common styles.
|
||||
*/
|
||||
export const commonStyles = (theme: Theme) => {
|
||||
return {
|
||||
'.empty-list': {
|
||||
listStyleType: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
},
|
||||
|
||||
'.mute-dialog': {
|
||||
'& .separator-line': {
|
||||
margin: `${theme.spacing(4)} 0 ${theme.spacing(4)} -20px`,
|
||||
padding: '0 20px',
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
background: '#5E6D7A'
|
||||
},
|
||||
|
||||
'& .control-row': {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: theme.spacing(3),
|
||||
|
||||
'& label': {
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'.overflow-menu-item': {
|
||||
alignItems: 'center',
|
||||
color: theme.palette.text01,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 400,
|
||||
height: 40,
|
||||
lineHeight: '1.5rem',
|
||||
padding: '8px 16px',
|
||||
boxSizing: 'border-box' as const,
|
||||
'& > div': {
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
},
|
||||
|
||||
'&.unclickable': {
|
||||
cursor: 'default'
|
||||
},
|
||||
|
||||
'&.disabled': {
|
||||
cursor: 'initial',
|
||||
color: theme.palette.text03,
|
||||
|
||||
'&:hover': {
|
||||
background: 'none'
|
||||
},
|
||||
|
||||
'& svg': {
|
||||
fill: theme.palette.text03
|
||||
}
|
||||
},
|
||||
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover': {
|
||||
background: theme.palette.action02Hover
|
||||
},
|
||||
'&.unclickable:hover': {
|
||||
background: 'inherit'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'.overflow-menu-item-icon': {
|
||||
marginRight: '16px',
|
||||
|
||||
'& i': {
|
||||
display: 'inline',
|
||||
fontSize: '1.5rem'
|
||||
},
|
||||
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&i:hover': {
|
||||
backgroundColor: 'initial'
|
||||
}
|
||||
},
|
||||
|
||||
'& img': {
|
||||
maxWidth: 24,
|
||||
maxHeight: 24
|
||||
},
|
||||
|
||||
'& svg': {
|
||||
fill: theme.palette.text01,
|
||||
height: 20,
|
||||
width: 20
|
||||
}
|
||||
},
|
||||
|
||||
'.prejoin-dialog': {
|
||||
backgroundColor: theme.palette.uiBackground,
|
||||
boxShadow: '0px 2px 20px rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
color: '#fff',
|
||||
height: '400px',
|
||||
width: '375px',
|
||||
|
||||
'.prejoin-dialog--small': {
|
||||
height: 300,
|
||||
width: 400
|
||||
},
|
||||
|
||||
'.prejoin-dialog-label': {
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.5rem'
|
||||
},
|
||||
|
||||
'.prejoin-dialog-label-num': {
|
||||
background: '#2b3b4b',
|
||||
border: '1px solid #A4B8D1',
|
||||
borderRadius: '50%',
|
||||
color: '#fff',
|
||||
display: 'inline-block',
|
||||
height: '24px',
|
||||
marginRight: theme.spacing(2),
|
||||
width: '24px'
|
||||
},
|
||||
|
||||
'.prejoin-dialog-container': {
|
||||
alignItems: 'center',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
height: '100dvh',
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
width: '100vw',
|
||||
zIndex: 3
|
||||
},
|
||||
|
||||
'.prejoin-dialog-flag': {
|
||||
display: 'inline-block',
|
||||
marginRight: theme.spacing(2),
|
||||
transform: 'scale(1.2)'
|
||||
},
|
||||
|
||||
'.prejoin-dialog-title': {
|
||||
display: 'inline-block',
|
||||
fontSize: '1.5rem',
|
||||
lineHeight: '2rem'
|
||||
},
|
||||
|
||||
'.prejoin-dialog-icon': {
|
||||
cursor: 'pointer'
|
||||
},
|
||||
|
||||
'.prejoin-dialog-btn': {
|
||||
marginBottom: '8px'
|
||||
},
|
||||
|
||||
'.prejoin-dialog-dialin-container': {
|
||||
textAlign: 'center' as const
|
||||
},
|
||||
|
||||
'.prejoin-dialog-delimiter': {
|
||||
background: theme.palette.ui03,
|
||||
border: '0',
|
||||
height: '1px',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
width: '100%'
|
||||
},
|
||||
|
||||
'.prejoin-dialog-delimiter-container': {
|
||||
margin: `${theme.spacing(4)} 0`,
|
||||
position: 'relative' as const
|
||||
},
|
||||
|
||||
'.prejoin-dialog-delimiter-txt-container': {
|
||||
position: 'absolute' as const,
|
||||
textAlign: 'center' as const,
|
||||
top: '-8px',
|
||||
width: '100%'
|
||||
},
|
||||
|
||||
'.prejoin-dialog-delimiter-txt': {
|
||||
background: theme.palette.uiBackground,
|
||||
color: theme.palette.text01,
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'uppercase' as const,
|
||||
padding: `0 ${theme.spacing(2)}`
|
||||
}
|
||||
},
|
||||
|
||||
'.prejoin-dialog-btn': {
|
||||
'&.primary, &.prejoin-dialog-btn.text': {
|
||||
width: '310px'
|
||||
}
|
||||
},
|
||||
|
||||
'.toolbox-icon': {
|
||||
display: 'flex',
|
||||
borderRadius: 3,
|
||||
flexDirection: 'column' as const,
|
||||
fontSize: '1.5rem',
|
||||
height: 48,
|
||||
justifyContent: 'center',
|
||||
width: 48,
|
||||
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.ui04
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.ui03
|
||||
}
|
||||
},
|
||||
[theme.breakpoints.down(320)]: {
|
||||
height: 36,
|
||||
width: 36
|
||||
},
|
||||
|
||||
'&.toggled': {
|
||||
backgroundColor: theme.palette.ui03
|
||||
},
|
||||
|
||||
'&.disabled': {
|
||||
cursor: 'initial !important',
|
||||
backgroundColor: `${theme.palette.disabled01} !important`,
|
||||
|
||||
'& svg': {
|
||||
fill: `${theme.palette.text03} !important`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'.toolbox-button': {
|
||||
color: theme.palette.text01,
|
||||
cursor: 'pointer',
|
||||
display: 'inline-block',
|
||||
lineHeight: '3rem',
|
||||
textAlign: 'center' as const
|
||||
},
|
||||
|
||||
'.toolbox-content-items': {
|
||||
background: theme.palette.ui01,
|
||||
borderRadius: 6,
|
||||
margin: '0 auto',
|
||||
padding: 6,
|
||||
textAlign: 'center' as const,
|
||||
pointerEvents: 'all' as const,
|
||||
display: 'flex',
|
||||
boxShadow: '0px 2px 8px 4px rgba(0, 0, 0, 0.25), 0px 0px 0px 1px rgba(0, 0, 0, 0.15)',
|
||||
|
||||
'& > div': {
|
||||
marginRight: theme.spacing(2),
|
||||
|
||||
'&:last-of-type': {
|
||||
marginRight: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
25
react/features/base/ui/functions.any.ts
Normal file
25
react/features/base/ui/functions.any.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Base font size in pixels (standard is 16px = 1rem)
|
||||
const BASE_FONT_SIZE = 16;
|
||||
|
||||
/**
|
||||
* Converts rem to pixels.
|
||||
*
|
||||
* @param {string} remValue - The value in rem units (e.g. '0.875rem').
|
||||
* @returns {number}
|
||||
*/
|
||||
export function remToPixels(remValue: string): number {
|
||||
const numericValue = parseFloat(remValue.replace('rem', ''));
|
||||
|
||||
return Math.round(numericValue * BASE_FONT_SIZE);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts pixels to rem.
|
||||
*
|
||||
* @param {number} pixels - The value in pixels.
|
||||
* @returns {string}
|
||||
* */
|
||||
export function pixelsToRem(pixels: number): string {
|
||||
return `${(pixels / BASE_FONT_SIZE).toFixed(3)}rem`;
|
||||
}
|
||||
51
react/features/base/ui/functions.native.ts
Normal file
51
react/features/base/ui/functions.native.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { DefaultTheme } from 'react-native-paper';
|
||||
|
||||
import { remToPixels } from './functions.any';
|
||||
import { createColorTokens } from './utils';
|
||||
|
||||
export * from './functions.any';
|
||||
|
||||
/**
|
||||
* Converts all rem to pixels in an object.
|
||||
*
|
||||
* @param {Object} obj - The object to convert rem values in.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function convertRemValues(obj: any): any {
|
||||
const converted: { [key: string]: any; } = {};
|
||||
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
Object.entries(obj).forEach(([ key, value ]) => {
|
||||
if (typeof value === 'string' && value.includes('rem')) {
|
||||
converted[key] = remToPixels(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
converted[key] = convertRemValues(value);
|
||||
} else {
|
||||
converted[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a React Native Paper theme based on local UI tokens.
|
||||
*
|
||||
* @param {Object} arg - The ui tokens.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createNativeTheme({ font, colorMap, shape, spacing, typography }: any): any {
|
||||
return {
|
||||
...DefaultTheme,
|
||||
palette: createColorTokens(colorMap),
|
||||
shape,
|
||||
spacing,
|
||||
typography: {
|
||||
font,
|
||||
...convertRemValues(typography)
|
||||
}
|
||||
};
|
||||
}
|
||||
121
react/features/base/ui/functions.web.ts
Normal file
121
react/features/base/ui/functions.web.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { Theme, adaptV4Theme, createTheme } from '@mui/material/styles';
|
||||
|
||||
import { ITypography, IPalette as Palette1 } from '../ui/types';
|
||||
|
||||
import { createColorTokens, createTypographyTokens } from './utils';
|
||||
|
||||
export * from './functions.any';
|
||||
|
||||
declare module '@mui/material/styles' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface Palette extends Palette1 {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface TypographyVariants extends ITypography {}
|
||||
}
|
||||
|
||||
interface ThemeProps {
|
||||
breakpoints: Object;
|
||||
colorMap: Object;
|
||||
font: Object;
|
||||
shape: Object;
|
||||
spacing: Array<number>;
|
||||
typography: Object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MUI theme based on local UI tokens.
|
||||
*
|
||||
* @param {Object} arg - The ui tokens.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createWebTheme({ font, colorMap, shape, spacing, typography, breakpoints }: ThemeProps) {
|
||||
return createTheme(adaptV4Theme({
|
||||
spacing,
|
||||
palette: createColorTokens(colorMap),
|
||||
shape,
|
||||
typography: {
|
||||
// @ts-ignore
|
||||
font,
|
||||
...createTypographyTokens(typography)
|
||||
},
|
||||
breakpoints
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first styled ancestor component of an element.
|
||||
*
|
||||
* @param {HTMLElement|null} target - Element to look up.
|
||||
* @param {string} cssClass - Styled component reference.
|
||||
* @returns {HTMLElement|null} Ancestor.
|
||||
*/
|
||||
export const findAncestorByClass = (target: HTMLElement | null, cssClass: string): HTMLElement | null => {
|
||||
if (!target || target.classList.contains(cssClass)) {
|
||||
return target;
|
||||
}
|
||||
|
||||
return findAncestorByClass(target.parentElement, cssClass);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the passed element is visible in the viewport.
|
||||
*
|
||||
* @param {Element} element - The element.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isElementInTheViewport(element?: Element): boolean {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!document.body.contains(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { innerHeight, innerWidth } = window;
|
||||
const { bottom, left, right, top } = element.getBoundingClientRect();
|
||||
|
||||
if (bottom <= innerHeight && top >= 0 && left >= 0 && right <= innerWidth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const enterKeyElements = [ 'select', 'textarea', 'summary', 'a' ];
|
||||
|
||||
/**
|
||||
* Informs whether or not the given element does something on its own when pressing the Enter key.
|
||||
*
|
||||
* This is useful to correctly submit custom made "forms" that are not using the native form element,
|
||||
* only when the user is not using an element that needs the enter key to work.
|
||||
* Note the implementation is incomplete and should be updated as needed if more complex use cases arise
|
||||
* (for example, the Tabs aria pattern is not handled).
|
||||
*
|
||||
* @param {Element} element - The element.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function operatesWithEnterKey(element: Element): boolean {
|
||||
if (enterKeyElements.includes(element.tagName.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.tagName.toLowerCase() === 'button' && element.getAttribute('role') === 'button') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a common spacing from the bottom of the page for floating elements over the video space.
|
||||
*
|
||||
* @param {Theme} theme - The current theme.
|
||||
* @param {boolean} isToolbarVisible - Whether the toolbar is visible or not.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getVideospaceFloatingElementsBottomSpacing(theme: Theme, isToolbarVisible: boolean) {
|
||||
return parseInt(isToolbarVisible ? theme.spacing(12) : theme.spacing(6), 10);
|
||||
}
|
||||
77
react/features/base/ui/hooks/useContextMenu.web.ts
Normal file
77
react/features/base/ui/hooks/useContextMenu.web.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { findAncestorByClass } from '../functions.web';
|
||||
|
||||
|
||||
type RaiseContext<T> = {
|
||||
|
||||
/**
|
||||
* The entity for which the menu is context menu is raised.
|
||||
*/
|
||||
entity?: T;
|
||||
|
||||
/**
|
||||
* Target elements against which positioning calculations are made.
|
||||
*/
|
||||
offsetTarget?: HTMLElement | null;
|
||||
};
|
||||
|
||||
const initialState = Object.freeze({});
|
||||
|
||||
const useContextMenu = <T>(): [(force?: boolean | Object) => void,
|
||||
(entity: T, target: HTMLElement | null) => void,
|
||||
(entity: T) => (e?: MouseEvent) => void,
|
||||
() => void,
|
||||
() => void,
|
||||
RaiseContext<T>] => {
|
||||
const [ raiseContext, setRaiseContext ] = useState < RaiseContext<T> >(initialState);
|
||||
const isMouseOverMenu = useRef(false);
|
||||
|
||||
const lowerMenu = useCallback((force: boolean | Object = false) => {
|
||||
/**
|
||||
* We are tracking mouse movement over the active participant item and
|
||||
* the context menu. Due to the order of enter/leave events, we need to
|
||||
* defer checking if the mouse is over the context menu with
|
||||
* queueMicrotask.
|
||||
*/
|
||||
window.queueMicrotask(() => {
|
||||
if (isMouseOverMenu.current && !(force === true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (raiseContext !== initialState || force) {
|
||||
setRaiseContext(initialState);
|
||||
}
|
||||
});
|
||||
}, [ raiseContext ]);
|
||||
|
||||
const raiseMenu = useCallback((entity: T, target: HTMLElement | null) => {
|
||||
setRaiseContext({
|
||||
entity,
|
||||
offsetTarget: findAncestorByClass(target, 'list-item-container')
|
||||
});
|
||||
}, [ raiseContext ]);
|
||||
|
||||
const toggleMenu = useCallback((entity: T) => (e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
const { entity: raisedEntity } = raiseContext;
|
||||
|
||||
if (raisedEntity && raisedEntity === entity) {
|
||||
lowerMenu();
|
||||
} else {
|
||||
raiseMenu(entity, e?.target as HTMLElement);
|
||||
}
|
||||
}, [ raiseContext ]);
|
||||
|
||||
const menuEnter = useCallback(() => {
|
||||
isMouseOverMenu.current = true;
|
||||
}, []);
|
||||
|
||||
const menuLeave = useCallback(() => {
|
||||
isMouseOverMenu.current = false;
|
||||
}, [ lowerMenu ]);
|
||||
|
||||
return [ lowerMenu, raiseMenu, toggleMenu, menuEnter, menuLeave, raiseContext ];
|
||||
};
|
||||
|
||||
export default useContextMenu;
|
||||
14
react/features/base/ui/jitsiTokens.json
Normal file
14
react/features/base/ui/jitsiTokens.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"success05": "#1EC26A",
|
||||
|
||||
"support05": "#73348C",
|
||||
"support06": "#6A50D3",
|
||||
|
||||
"surface01": "#040404",
|
||||
"surface02": "#141414",
|
||||
"surface03": "#292929",
|
||||
"surface05": "#525252",
|
||||
"surface08": "#A3A3A3",
|
||||
|
||||
"warning06": "#FFD600"
|
||||
}
|
||||
211
react/features/base/ui/tokens.json
Normal file
211
react/features/base/ui/tokens.json
Normal file
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"action01": "#4687ED",
|
||||
"action02": "#E0E0E0",
|
||||
"action03": "#D83848",
|
||||
"action04": "#246FE5",
|
||||
"action05": "#4687ED",
|
||||
"action06": "#0056E0",
|
||||
"action07": "#D7E3F9",
|
||||
"action08": "#99BBF3",
|
||||
"action09": "#4687ED",
|
||||
|
||||
"active01": "#0056E0",
|
||||
"active02": "#C2C2C2",
|
||||
"active03": "#CB2233",
|
||||
"active04": "#189B55",
|
||||
"active05": "#666666",
|
||||
"active07": "#0056E0",
|
||||
"active08": "#525252",
|
||||
"active09": "#3D3D3D",
|
||||
"active10": "#3D3D3D",
|
||||
"active11": "#CCDDF9",
|
||||
"active12": "#666666",
|
||||
|
||||
"alertGreen": "#189B55",
|
||||
"alertRed": "#F24D5F",
|
||||
"alertYellow": "#F8AE1A",
|
||||
|
||||
"body01": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"fontSize": "0.875rem",
|
||||
"lineHeight": "1.25rem",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "-0.006rem"
|
||||
},
|
||||
"body02": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"fontSize": "1rem",
|
||||
"lineHeight": "1.5rem",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "-0.011rem"
|
||||
},
|
||||
"bodyBold01": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"fontSize": "0.875rem",
|
||||
"lineHeight": "1.25rem",
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": "-0.006rem"
|
||||
},
|
||||
"bodyBold02": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"fontSize": "1rem",
|
||||
"lineHeight": "1.5rem",
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": "-0.011rem"
|
||||
},
|
||||
|
||||
"brand01": "#FFFFFF",
|
||||
|
||||
"bulletList01": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"fontSize": "0.875rem",
|
||||
"lineHeight": "1.5rem",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "-0.006rem"
|
||||
},
|
||||
|
||||
"data01": "#C15C97",
|
||||
"data02": "#4079D3",
|
||||
"data03": "#A276B2",
|
||||
"data04": "#4CAC9C",
|
||||
"data05": "#E76782",
|
||||
"data06": "#8CA7D1",
|
||||
"data07": "#B23683",
|
||||
"data08": "#FFA95E",
|
||||
"data09": "#8B559F",
|
||||
"data10": "#009B89",
|
||||
"data11": "#858585",
|
||||
|
||||
"dataText01": "#292929",
|
||||
"dataText02": "#292929",
|
||||
"dataText03": "#292929",
|
||||
"dataText04": "#000000",
|
||||
"dataText05": "#000000",
|
||||
"dataText06": "#000000",
|
||||
"dataText07": "#FFFFFF",
|
||||
"dataText08": "#000000",
|
||||
"dataText09": "#FFFFFF",
|
||||
"dataText10": "#FFFFFF",
|
||||
"dataText11": "#000000",
|
||||
|
||||
"disabled01": "#C2C2C2",
|
||||
"disabled02": "#666666",
|
||||
"disabled03": "#C2C2C2",
|
||||
"disabled04": "#858585",
|
||||
|
||||
"error01": "#F24D5F",
|
||||
|
||||
"focus01": "#D7E3F9",
|
||||
|
||||
"heading01": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"fontSize": "1.25rem",
|
||||
"lineHeight": "1.75rem",
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": "-0.017rem"
|
||||
},
|
||||
"heading02": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"fontSize": "1.75rem",
|
||||
"lineHeight": "2.5rem",
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": "-0.020rem"
|
||||
},
|
||||
|
||||
"highlight01": "#F8AE1A",
|
||||
|
||||
"hover01": "#4687ED",
|
||||
"hover02": "#F1F1F1",
|
||||
"hover03": "#F24D5F",
|
||||
"hover04": "#4BCE88",
|
||||
"hover05": "#3D3D3D",
|
||||
"hover06": "#666666",
|
||||
"hover07": "#99BBF3",
|
||||
"hover08": "#2F2E32",
|
||||
"hover09": "#666666",
|
||||
"hover10": "#292929",
|
||||
"hover11": "#99BBF3",
|
||||
"hover12": "#3D3D3D",
|
||||
"hover13": "#525252",
|
||||
|
||||
"icon01": "#FFFFFF",
|
||||
"icon02": "#FFFFFF",
|
||||
"icon03": "#666666",
|
||||
"icon04": "#0056E0",
|
||||
"icon05": "#292929",
|
||||
"icon06": "#666666",
|
||||
"icon07": "#858585",
|
||||
"icon08": "#292929",
|
||||
|
||||
"info01": "#666666",
|
||||
|
||||
"label01": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"fontSize": "0.75rem",
|
||||
"lineHeight": "1rem",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "normal"
|
||||
},
|
||||
"labelBold01": {
|
||||
"fontFamily": "Inter, sans-serif",
|
||||
"fontSize": "0.75rem",
|
||||
"lineHeight": "1rem",
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": "normal"
|
||||
},
|
||||
|
||||
"overlay01": "#292929BF",
|
||||
|
||||
"shadow01": "#141414",
|
||||
"shadowHigh": "0px 2px 20px 9px #141414",
|
||||
"shadowLow": "0px 1px 2px 1px #141414",
|
||||
"shadowMedium": "0px 2px 8px 2px #141414",
|
||||
|
||||
"statusAvailable01": "#189B55",
|
||||
"statusAway01": "#DD7011",
|
||||
"statusBusy01": "#F24D5F",
|
||||
"statusOffline01": "#666666",
|
||||
"statusWrapup01": "#8B559F",
|
||||
|
||||
"success01": "#189B55",
|
||||
|
||||
"textColor01": "#FFFFFF",
|
||||
"textColor02": "#C2C2C2",
|
||||
"textColor04": "#292929",
|
||||
"textColor05": "#C2C2C2",
|
||||
"textColor06": "#FFFFFF",
|
||||
"textColor07": "#292929",
|
||||
"textColor08": "#FFFFFF",
|
||||
"textColor09": "#000000",
|
||||
"textColor10": "#E7E3FF",
|
||||
|
||||
"ui01": "#666666",
|
||||
"ui02": "#3D3D3D",
|
||||
"ui03": "#858585",
|
||||
"ui04": "#FFFFFF",
|
||||
"ui05": "#2F2E32",
|
||||
"ui06": "#171719",
|
||||
"ui07": "#F1F1F1",
|
||||
"ui08": "#E0E0E0",
|
||||
"ui09": "#CCDDF9",
|
||||
"ui10": "#666666",
|
||||
"ui11": "#0C0C0D",
|
||||
"ui12": "#212124",
|
||||
"ui13": "#003486",
|
||||
"ui14": "#127440",
|
||||
"ui15": "#00225A",
|
||||
"ui16": "#F5D3D6",
|
||||
"ui17": "#FEEFD1",
|
||||
"ui18": "#D2F3E1",
|
||||
"ui19": "#E0E0E0",
|
||||
"ui20": "#E8DCEC",
|
||||
"ui21": "#C2C2C2",
|
||||
"ui22": "#CCDDF9",
|
||||
"ui23": "#3C2F8E",
|
||||
"ui24": "#5F50BE",
|
||||
"ui25": "#171719",
|
||||
|
||||
"warning01": "#F8AE1A",
|
||||
"warning02": "#F8AE1A",
|
||||
"warning03": "#9F701C"
|
||||
}
|
||||
81
react/features/base/ui/types.ts
Normal file
81
react/features/base/ui/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
interface ITypographyType {
|
||||
fontSize: string;
|
||||
fontWeight: 'normal' | 'bold' | 'bolder' | 'lighter' | number;
|
||||
letterSpacing: number;
|
||||
lineHeight: string;
|
||||
}
|
||||
|
||||
export interface IPalette {
|
||||
action01: string;
|
||||
action01Active: string;
|
||||
action01Hover: string;
|
||||
action02: string;
|
||||
action02Active: string;
|
||||
action02Hover: string;
|
||||
action03: string;
|
||||
action03Active: string;
|
||||
action03Hover: string;
|
||||
actionDanger: string;
|
||||
actionDangerActive: string;
|
||||
actionDangerHover: string;
|
||||
disabled01: string;
|
||||
field01: string;
|
||||
focus01: string;
|
||||
icon01: string;
|
||||
icon02: string;
|
||||
icon03: string;
|
||||
icon04: string;
|
||||
iconError: string;
|
||||
link01: string;
|
||||
link01Active: string;
|
||||
link01Hover: string;
|
||||
success01: string;
|
||||
success02: string;
|
||||
support01: string;
|
||||
support02: string;
|
||||
support03: string;
|
||||
support04: string;
|
||||
support05: string;
|
||||
support06: string;
|
||||
support07: string;
|
||||
support08: string;
|
||||
support09: string;
|
||||
text01: string;
|
||||
text02: string;
|
||||
text03: string;
|
||||
text04: string;
|
||||
textError: string;
|
||||
ui01: string;
|
||||
ui02: string;
|
||||
ui03: string;
|
||||
ui04: string;
|
||||
ui05: string;
|
||||
ui06: string;
|
||||
ui07: string;
|
||||
ui08: string;
|
||||
ui09: string;
|
||||
ui10: string;
|
||||
uiBackground: string;
|
||||
warning01: string;
|
||||
warning02: string;
|
||||
}
|
||||
|
||||
export interface ITypography {
|
||||
bodyLongBold: ITypographyType;
|
||||
bodyLongBoldLarge: ITypographyType;
|
||||
bodyLongRegular: ITypographyType;
|
||||
bodyLongRegularLarge: ITypographyType;
|
||||
bodyShortBold: ITypographyType;
|
||||
bodyShortBoldLarge: ITypographyType;
|
||||
bodyShortRegular: ITypographyType;
|
||||
bodyShortRegularLarge: ITypographyType;
|
||||
bodyShortRegularSmall: ITypographyType;
|
||||
heading1: ITypographyType;
|
||||
heading2: ITypographyType;
|
||||
heading3: ITypographyType;
|
||||
heading4: ITypographyType;
|
||||
heading5: ITypographyType;
|
||||
heading6: ITypographyType;
|
||||
labelBold: ITypographyType;
|
||||
labelRegular: ITypographyType;
|
||||
}
|
||||
42
react/features/base/ui/utils.ts
Normal file
42
react/features/base/ui/utils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { merge } from 'lodash-es';
|
||||
|
||||
import * as jitsiTokens from './jitsiTokens.json';
|
||||
import * as tokens from './tokens.json';
|
||||
|
||||
/**
|
||||
* Creates the color tokens based on the color theme and the association map.
|
||||
*
|
||||
* @param {Object} colorMap - A map between the token name and the actual color value.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createColorTokens(colorMap: Object): any {
|
||||
const allTokens = merge({}, tokens, jitsiTokens);
|
||||
|
||||
return Object.entries(colorMap)
|
||||
.reduce((result, [ token, value ]: [any, string]) => {
|
||||
const color = allTokens[value as keyof typeof allTokens] || value;
|
||||
|
||||
return Object.assign(result, { [token]: color });
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the typography tokens based on the typography theme and the association map.
|
||||
*
|
||||
* @param {Object} typography - A map between the token name and the actual typography value.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createTypographyTokens(typography: Object): any {
|
||||
const allTokens = merge({}, tokens, jitsiTokens);
|
||||
|
||||
return Object.entries(typography)
|
||||
.reduce((result, [ token, value ]: [any, any]) => {
|
||||
let typographyValue = value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
typographyValue = allTokens[value as keyof typeof allTokens] || value;
|
||||
}
|
||||
|
||||
return Object.assign(result, { [token]: typographyValue });
|
||||
}, {});
|
||||
}
|
||||
Reference in New Issue
Block a user