init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View 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
}
};

View 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
}));

View 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
});

View 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;

View File

@@ -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>;
}

View 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);

View 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;

View 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;

View 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;

View 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;

View 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'
}
};

View 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
}
};

View 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;

View 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;
};

View 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;
}

View File

@@ -0,0 +1,6 @@
/**
* Prejoin / premeeting screen.
*/
// Maps SCSS variable $prejoinDefaultContentWidth
export const PREJOIN_DEFAULT_CONTENT_WIDTH = '336px';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;

View 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;

View 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);

View 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;

View 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;

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View File

@@ -0,0 +1 @@
export * from './constants.any';

View 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
}
}
}
};
};

View 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`;
}

View 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)
}
};
}

View 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);
}

View 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;

View File

@@ -0,0 +1,14 @@
{
"success05": "#1EC26A",
"support05": "#73348C",
"support06": "#6A50D3",
"surface01": "#040404",
"surface02": "#141414",
"surface03": "#292929",
"surface05": "#525252",
"surface08": "#A3A3A3",
"warning06": "#FFD600"
}

View 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"
}

View 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;
}

View 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 });
}, {});
}