mattermost-mobile/app/components/option_item/index.tsx

365 lines
11 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {type LayoutChangeEvent, Platform, Switch, Text, TouchableOpacity, View} from 'react-native';
import UserChip from '@components/chips/user_chip';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import OptionIcon from './option_icon';
import RadioItem from './radio_item';
import type UserModel from '@typings/database/models/servers/user';
const TouchableOptionTypes = {
ARROW: 'arrow',
DEFAULT: 'default',
RADIO: 'radio',
REMOVE: 'remove',
SELECT: 'select',
LINK: 'link',
} as const;
const OptionTypeConst = {
NONE: 'none',
TOGGLE: 'toggle',
...TouchableOptionTypes,
} as const;
export type OptionType = typeof OptionTypeConst[keyof typeof OptionTypeConst];
export const ITEM_HEIGHT = 48;
const DESCRIPTION_MARGIN_TOP = 2;
export function getItemHeightWithDescription(descriptionNumberOfLines: number) {
const labelHeight = 24; // typography 200 line height
const descriptionLineHeight = 16; // typography 75 line height;
return Math.max(48, labelHeight + DESCRIPTION_MARGIN_TOP + (descriptionLineHeight * descriptionNumberOfLines));
}
const hitSlop = {top: 11, bottom: 11, left: 11, right: 11};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
actionContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
},
container: {
flexDirection: 'row',
alignItems: 'center',
minHeight: ITEM_HEIGHT,
gap: 12,
justifyContent: 'space-between',
paddingVertical: 12,
},
disabled: {
opacity: 0.6,
},
destructive: {
color: theme.dndIndicator,
},
description: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
marginTop: DESCRIPTION_MARGIN_TOP,
},
iconContainer: {marginRight: 16},
info: {
textAlign: 'right',
color: changeOpacity(theme.centerChannelColor, 0.56),
...typography('Body', 100),
},
inlineLabel: {
flexDirection: 'row',
flexShrink: 1,
justifyContent: 'center',
},
inlineLabelText: {
color: theme.centerChannelColor,
...typography('Body', 200, 'SemiBold'),
},
inlineDescription: {
color: theme.centerChannelColor,
...typography('Body', 200),
},
label: {
flexShrink: 1,
justifyContent: 'center',
},
labelContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
labelText: {
color: theme.centerChannelColor,
...typography('Body', 200),
},
shrink: {
flexShrink: 1,
},
};
});
type UserChipData = {
user: UserModel;
onPress: (id: string) => void;
teammateNameDisplay: string;
}
export type OptionItemProps = {
action?: (React.Dispatch<React.SetStateAction<string | boolean>>)|((value: string | boolean) => void);
description?: string;
destructive?: boolean;
disabled?: boolean;
icon?: string;
iconColor?: string;
info?: string | UserChipData;
isInfoDestructive?: boolean;
inline?: boolean;
label: string;
onRemove?: () => void;
selected?: boolean;
testID?: string;
type: OptionType;
value?: string;
onLayout?: (event: LayoutChangeEvent) => void;
descriptionNumberOfLines?: number;
labelNumberOfLines?: number;
longInfo?: boolean;
nonDestructiveDescription?: boolean;
isRadioCheckmark?: boolean;
}
const OptionItem = ({
action,
description,
destructive,
disabled = false,
icon,
iconColor,
info,
isInfoDestructive = false,
inline = false,
label,
onRemove,
selected,
testID = 'optionItem',
type,
value,
onLayout,
descriptionNumberOfLines,
labelNumberOfLines = 2,
longInfo,
nonDestructiveDescription = false,
isRadioCheckmark = false,
}: OptionItemProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const isInLine = inline && Boolean(description);
const shouldDescriptionShowDestructive = destructive && !nonDestructiveDescription;
const labelContainerStyle = useMemo(() => {
const extraStyle = longInfo ? {flex: undefined} : {};
const alignmentStyle = description ? {alignItems: 'flex-start' as const} : {};
return [styles.labelContainer, extraStyle, alignmentStyle];
}, [longInfo, styles.labelContainer, description]);
const labelStyle = useMemo(() => {
return isInLine ? styles.inlineLabel : styles.label;
}, [styles, isInLine]);
const labelTextStyle = useMemo(() => {
return [
isInLine ? styles.inlineLabelText : styles.labelText,
destructive && styles.destructive,
type === 'link' && {color: theme.linkColor},
];
}, [destructive, styles, isInLine, type, theme.linkColor]);
const descriptionTextStyle = useMemo(() => {
return [
isInLine ? styles.inlineDescription : styles.description,
shouldDescriptionShowDestructive && styles.destructive,
];
}, [
isInLine,
styles.inlineDescription,
styles.description,
styles.destructive,
shouldDescriptionShowDestructive,
]);
const actionContainerStyle = useMemo(() => {
const extraStyle = longInfo ? styles.shrink : {};
return [styles.actionContainer, extraStyle];
}, [longInfo, styles.actionContainer, styles.shrink]);
const containerStyle = useMemo(() => {
return disabled ? [styles.container, styles.disabled] : styles.container;
}, [disabled, styles.container, styles.disabled]);
let actionComponent;
let radioComponent;
if (type === OptionTypeConst.SELECT && selected) {
actionComponent = (
<CompassIcon
color={theme.linkColor}
name='check'
size={24}
testID={`${testID}.selected`}
/>
);
} else if (type === OptionTypeConst.RADIO) {
const radioComponentTestId = selected ? `${testID}.selected` : `${testID}.not_selected`;
radioComponent = (
<RadioItem
selected={Boolean(selected)}
testID={radioComponentTestId}
checkedBody={isRadioCheckmark}
/>
);
} else if (type === OptionTypeConst.TOGGLE) {
const trackColor = Platform.select({
ios: {true: theme.buttonBg, false: changeOpacity(theme.centerChannelColor, 0.16)},
default: {true: changeOpacity(theme.buttonBg, 0.32), false: changeOpacity(theme.centerChannelColor, 0.24)},
});
const thumbColor = Platform.select({
android: selected ? theme.buttonBg : '#F3F3F3', // Hardcoded color specified in ticket MM-45143
});
actionComponent = (
<Switch
onValueChange={action}
value={selected}
trackColor={trackColor}
thumbColor={thumbColor}
testID={`${testID}.toggled.${selected}.button`}
/>
);
} else if (type === OptionTypeConst.ARROW) {
actionComponent = (
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.32)}
name='chevron-right'
size={24}
testID={`${testID}.arrow.icon`}
/>
);
} else if (type === OptionTypeConst.REMOVE) {
actionComponent = (
<TouchableWithFeedback
hitSlop={hitSlop}
onPress={onRemove}
style={styles.iconContainer}
type='opacity'
testID={`${testID}.remove.button`}
>
<CompassIcon
name={'close'}
size={18}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
</TouchableWithFeedback>
);
}
const onPress = useCallback(() => {
action?.(value || '');
}, [value, action]);
let infoComponent;
if (typeof info === 'object') {
infoComponent = (
<View style={actionComponent ? undefined : styles.iconContainer}>
<UserChip
user={info.user}
onPress={info.onPress}
teammateNameDisplay={info.teammateNameDisplay}
/>
</View>
);
} else if (info) {
infoComponent = (
<Text
style={[styles.info, !actionComponent && styles.iconContainer, (destructive || isInfoDestructive) && {color: theme.dndIndicator}]}
testID={`${testID}.info`}
numberOfLines={1}
>
{info}
</Text>
);
if (actionComponent) {
// Wrap the text into another view to properly calculate
// the space available.
infoComponent = (
<View style={styles.shrink}>
{infoComponent}
</View>
);
}
}
const component = (
<View
testID={testID}
style={containerStyle}
onLayout={onLayout}
>
<View style={labelContainerStyle}>
{Boolean(icon) && (
<View style={styles.iconContainer}>
<OptionIcon
icon={icon!}
iconColor={iconColor}
destructive={destructive}
/>
</View>
)}
{type === OptionTypeConst.RADIO && radioComponent}
<View style={labelStyle}>
<Text
style={labelTextStyle}
testID={`${testID}.label`}
numberOfLines={labelNumberOfLines}
>
{label}
</Text>
{Boolean(description) &&
<Text
style={descriptionTextStyle}
testID={`${testID}.description`}
numberOfLines={descriptionNumberOfLines}
>
{description}
</Text>
}
</View>
</View>
{Boolean(actionComponent || infoComponent) &&
<View style={actionContainerStyle}>
{infoComponent}
{actionComponent}
</View>
}
</View>
);
if ((Object.values(TouchableOptionTypes) as string[]).includes(type)) {
return (
<TouchableOpacity onPress={onPress}>
{component}
</TouchableOpacity>
);
}
return component;
};
export default OptionItem;