Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions src/components/Avatar/AvatarIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import { StyleSheet, View } from 'react-native';
import type { StyleProp, ViewProps, ViewStyle } from 'react-native';

import { useInternalTheme } from '../../core/theming';
import { white } from '../../theme/colors';
import { cornerFull } from '../../theme/tokens/sys/shape';
import type { ThemeProp } from '../../theme/types';
import getContrastingColor from '../../utils/getContrastingColor';
import Icon from '../Icon';
import type { IconSource } from '../Icon';

const defaultSize = 64;
import { DEFAULT_SIZE, ICON_SIZE_RATIO, resolveAvatarColors } from './utils';

export type Props = ViewProps & {
/**
Expand Down Expand Up @@ -45,33 +43,34 @@ export type Props = ViewProps & {
*/
const Avatar = ({
icon,
size = defaultSize,
size = DEFAULT_SIZE,
style,
theme: themeOverrides,
...rest
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { backgroundColor = theme.colors?.primary, ...restStyle } =
StyleSheet.flatten(style) || {};
const textColor =
rest.color ??
getContrastingColor(backgroundColor, white, 'rgba(0, 0, 0, .54)');
const { backgroundColor, ...restStyle } = StyleSheet.flatten(style) || {};
const { background, textColor } = resolveAvatarColors({
theme,
backgroundColor,
color: rest.color,
});

return (
<View
style={[
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor,
borderRadius: cornerFull,
backgroundColor: background,
},
styles.container,
restStyle,
]}
{...rest}
>
<Icon source={icon} color={textColor} size={size * 0.6} />
<Icon source={icon} color={textColor} size={size * ICON_SIZE_RATIO} />
</View>
);
};
Expand Down
17 changes: 9 additions & 8 deletions src/components/Avatar/AvatarImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import type {
ViewStyle,
} from 'react-native';

import { DEFAULT_SIZE, resolveAvatarColors } from './utils';
import { useInternalTheme } from '../../core/theming';
import { cornerFull } from '../../theme/tokens/sys/shape';
import type { ThemeProp } from '../../theme/types';

const defaultSize = 64;

export type AvatarImageSource =
| ImageSourcePropType
| ((props: { size: number }) => React.ReactNode);
Expand Down Expand Up @@ -74,7 +74,7 @@ export type Props = ViewProps & {
* ```
*/
const AvatarImage = ({
size = defaultSize,
size = DEFAULT_SIZE,
source,
style,
onError,
Expand All @@ -87,17 +87,18 @@ const AvatarImage = ({
testID,
...rest
}: Props) => {
const { colors } = useInternalTheme(themeOverrides);
const { backgroundColor = colors?.primary } = StyleSheet.flatten(style) || {};
const theme = useInternalTheme(themeOverrides);
const { backgroundColor } = StyleSheet.flatten(style) || {};
const { background } = resolveAvatarColors({ theme, backgroundColor });

return (
<View
style={[
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor,
borderRadius: cornerFull,
backgroundColor: background,
},
style,
]}
Expand All @@ -108,7 +109,7 @@ const AvatarImage = ({
<Image
testID={testID}
source={source}
style={{ width: size, height: size, borderRadius: size / 2 }}
style={{ width: size, height: size, borderRadius: cornerFull }}
onError={onError}
onLayout={onLayout}
onLoad={onLoad}
Expand Down
24 changes: 12 additions & 12 deletions src/components/Avatar/AvatarText.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { StyleSheet, useWindowDimensions, View } from 'react-native';
import type { StyleProp, TextStyle, ViewProps, ViewStyle } from 'react-native';

import { DEFAULT_SIZE, resolveAvatarColors } from './utils';
import { useInternalTheme } from '../../core/theming';
import { white } from '../../theme/colors';
import { cornerFull } from '../../theme/tokens/sys/shape';
import type { ThemeProp } from '../../theme/types';
import getContrastingColor from '../../utils/getContrastingColor';
import Text from '../Typography/Text';

const defaultSize = 64;

export type Props = ViewProps & {
/**
* Initials to show as the text in the `Avatar`.
Expand Down Expand Up @@ -55,7 +53,7 @@ export type Props = ViewProps & {
*/
const AvatarText = ({
label,
size = defaultSize,
size = DEFAULT_SIZE,
style,
labelStyle,
color: customColor,
Expand All @@ -64,11 +62,12 @@ const AvatarText = ({
...rest
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { backgroundColor = theme.colors?.primary, ...restStyle } =
StyleSheet.flatten(style) || {};
const textColor =
customColor ??
getContrastingColor(backgroundColor, white, 'rgba(0, 0, 0, .54)');
const { backgroundColor, ...restStyle } = StyleSheet.flatten(style) || {};
const { background, textColor } = resolveAvatarColors({
theme,
backgroundColor,
color: customColor,
});
const { fontScale } = useWindowDimensions();

return (
Expand All @@ -77,8 +76,8 @@ const AvatarText = ({
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor,
borderRadius: cornerFull,
backgroundColor: background,
},
styles.container,
restStyle,
Expand All @@ -88,6 +87,7 @@ const AvatarText = ({
<Text
style={[
styles.text,
theme.fonts.titleMedium,
{
color: textColor,
fontSize: size / 2,
Expand Down
33 changes: 33 additions & 0 deletions src/components/Avatar/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ColorValue } from 'react-native';

import { white } from '../../theme/colors';
import type { InternalTheme } from '../../theme/types';
import getContrastingColor from '../../utils/getContrastingColor';

export const DEFAULT_SIZE = 64;
export const ICON_SIZE_RATIO = 0.6;

/**
* Resolve background and content colors for an avatar.
* - No custom background → MD3 container pair (primaryContainer / onPrimaryContainer).
* - Custom background → keep luminance-based contrast so arbitrary per-user
* colors stay legible (contentColorFor only handles known theme roles).
*/
export const resolveAvatarColors = ({
theme,
backgroundColor,
color,
}: {
theme: InternalTheme;
backgroundColor?: ColorValue;
color?: string;
}) => {
const usingDefault = backgroundColor == null;
const background = backgroundColor ?? theme.colors.primaryContainer;
const textColor =
color ??
(usingDefault
? theme.colors.onPrimaryContainer
: getContrastingColor(background, white, 'rgba(0, 0, 0, .54)'));
return { background, textColor };
};
18 changes: 14 additions & 4 deletions src/components/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { useInternalTheme } from '../core/theming';
import type { Elevation, ThemeProp } from '../theme/types';

const DEFAULT_MAX_WIDTH = 960;
const ICON_SIZE = 40;

type AnimationFinishedCallback = (result: { finished: boolean }) => void;

Expand Down Expand Up @@ -165,8 +166,10 @@ const Banner = ({
const showCallback = useLatestCallback(onShowAnimationFinished);
const hideCallback = useLatestCallback(onHideAnimationFinished);

const { duration, easing } = theme.motion;
const { scale } = theme.animation;
const animationDuration = (visible ? 250 : 200) * scale;
const animationDuration =
(visible ? duration.medium1 : duration.short4) * scale;

React.useEffect(() => {
const callback = visible ? showCallback : hideCallback;
Expand All @@ -175,12 +178,19 @@ const Banner = ({
visible ? 1 : 0,
{
duration: animationDuration,
easing: Easing.inOut(Easing.ease),
easing: Easing.bezier(...easing.standard),
reduceMotion: ReduceMotion.Never,
},
(finished) => scheduleOnRN(callback, { finished: finished ?? false })
);
}, [animationDuration, hideCallback, position, showCallback, visible]);
}, [
animationDuration,
easing,
hideCallback,
position,
showCallback,
visible,
]);

const surfaceStyle = useAnimatedStyle(() => ({
opacity: interpolate(position.value, [0, 0.1, 1], [0, 1, 1]),
Expand Down Expand Up @@ -234,7 +244,7 @@ const Banner = ({
<View style={styles.content}>
{icon ? (
<View style={styles.icon}>
<Icon source={icon} size={40} />
<Icon source={icon} size={ICON_SIZE} />
</View>
) : null}
<Text
Expand Down
1 change: 1 addition & 0 deletions src/components/DataTable/DataTableCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const CellContent = ({

return (
<Text
variant="bodyMedium"
style={textStyle}
numberOfLines={1}
maxFontSizeMultiplier={maxFontSizeMultiplier}
Expand Down
3 changes: 2 additions & 1 deletion src/components/DataTable/DataTablePagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ const DataTablePagination = ({
style={styles.optionsContainer}
>
<Text
variant="bodySmall"
style={[styles.label, { color: labelColor }]}
numberOfLines={3}
testID="select-page-dropdown-label"
Expand All @@ -317,6 +318,7 @@ const DataTablePagination = ({
</View>
)}
<Text
variant="bodySmall"
style={[styles.label, { color: labelColor }]}
numberOfLines={3}
aria-label={accessibilityLabel || 'label'}
Expand Down Expand Up @@ -352,7 +354,6 @@ const styles = StyleSheet.create({
marginVertical: 6,
},
label: {
fontSize: 12,
marginRight: 16,
},
button: {
Expand Down
14 changes: 8 additions & 6 deletions src/components/DataTable/DataTableTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,16 @@ const DataTableTitle = ({

const spin = useSharedValue(sortDirection === 'ascending' ? 0 : 180);

const { duration, easing } = theme.motion;
const { scale } = theme.animation;

React.useEffect(() => {
spin.value = withTiming(sortDirection === 'ascending' ? 0 : 180, {
duration: 150,
easing: Easing.inOut(Easing.ease),
duration: duration.short3 * scale,
easing: Easing.bezier(...easing.standard),
reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never,
});
}, [reduceMotion, sortDirection, spin]);
}, [duration, easing, reduceMotion, scale, sortDirection, spin]);

const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${spin.value}deg` }],
Expand Down Expand Up @@ -141,6 +144,7 @@ const DataTableTitle = ({
{icon}

<Text
variant="labelMedium"
style={[
styles.cell,
// height must scale with numberOfLines
Expand Down Expand Up @@ -192,10 +196,8 @@ const styles = StyleSheet.create({
},

cell: {
lineHeight: 24,
fontSize: 12,
fontWeight: '500',
alignItems: 'center',
lineHeight: 24,
},

sorted: {
Expand Down
29 changes: 29 additions & 0 deletions src/components/__tests__/Banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,35 @@ describe('animations', () => {
hideCallback = undefined;
});

it('uses zero-duration animations when animation scale is disabled', async () => {
const view = await render(
<Banner
visible={false}
onShowAnimationFinished={showCallback}
theme={{ animation: { scale: 0 } }}
>
Text
</Banner>
);

await view.rerender(
<Banner
visible
onShowAnimationFinished={showCallback}
theme={{ animation: { scale: 0 } }}
>
Text
</Banner>
);
// A disabled animation scale settles within a frame instead of taking the
// full `motion.duration.medium1`.
await act(() => {
jest.advanceTimersByTime(16);
});

expect(showCallback).toHaveBeenCalled();
});

describe('when component is rendered hidden', () => {
// This behaviour is probably a bug. Needs triage before next version.
it('will fire onHideAnimationFinished on mount', async () => {
Expand Down
Loading