From 20035d15edf7acfb292247f7a8c5e501192cb848 Mon Sep 17 00:00:00 2001 From: Cal Leung Date: Tue, 28 Jun 2022 22:00:37 -0700 Subject: [PATCH] Component/4225 icon button (#4583) * [WIP] Push up icon changes * Introduce assets * Create script to generate Icon types based on icons in assets directory * Change Icon component to use SVGs. Create script to auto generate SVG types. Use an svg transformer to allow for importing SVGs as components * Update ignores on Icon.assets * Update generate-assets script to add ignores * Remove icon test - SVG mock issue. Update Icon docs with color prop * Fix useStyles for other components * Update Icon README * Correct read me * Reintroduce react-native-svg-asset-plugin * Provide better typing for svg * Create AvatarIcon * Create IconButton * fix test --- .../AvatarIcon/AvatarIcon.stories.tsx | 21 ++++++ .../AvatarIcon/AvatarIcon.styles.ts | 35 ++++++++++ .../components/AvatarIcon/AvatarIcon.test.tsx | 14 ++++ .../components/AvatarIcon/AvatarIcon.tsx | 28 ++++++++ .../components/AvatarIcon/AvatarIcon.types.ts | 35 ++++++++++ .../components/AvatarIcon/README.md | 23 +++++++ .../__snapshots__/AvatarIcon.test.tsx.snap | 20 ++++++ .../components/AvatarIcon/index.ts | 1 + .../IconButton/IconButton.stories.tsx | 30 ++++++++ .../IconButton/IconButton.styles.ts | 32 +++++++++ .../components/IconButton/IconButton.test.tsx | 18 +++++ .../components/IconButton/IconButton.tsx | 69 +++++++++++++++++++ .../components/IconButton/IconButton.types.ts | 37 ++++++++++ .../components/IconButton/README.md | 31 +++++++++ .../__snapshots__/IconButton.test.tsx.snap | 24 +++++++ .../components/IconButton/index.ts | 2 + storybook/storyLoader.js | 4 ++ 17 files changed, 424 insertions(+) create mode 100644 app/component-library/components/AvatarIcon/AvatarIcon.stories.tsx create mode 100644 app/component-library/components/AvatarIcon/AvatarIcon.styles.ts create mode 100644 app/component-library/components/AvatarIcon/AvatarIcon.test.tsx create mode 100644 app/component-library/components/AvatarIcon/AvatarIcon.tsx create mode 100644 app/component-library/components/AvatarIcon/AvatarIcon.types.ts create mode 100644 app/component-library/components/AvatarIcon/README.md create mode 100644 app/component-library/components/AvatarIcon/__snapshots__/AvatarIcon.test.tsx.snap create mode 100644 app/component-library/components/AvatarIcon/index.ts create mode 100644 app/component-library/components/IconButton/IconButton.stories.tsx create mode 100644 app/component-library/components/IconButton/IconButton.styles.ts create mode 100644 app/component-library/components/IconButton/IconButton.test.tsx create mode 100644 app/component-library/components/IconButton/IconButton.tsx create mode 100644 app/component-library/components/IconButton/IconButton.types.ts create mode 100644 app/component-library/components/IconButton/README.md create mode 100644 app/component-library/components/IconButton/__snapshots__/IconButton.test.tsx.snap create mode 100644 app/component-library/components/IconButton/index.ts diff --git a/app/component-library/components/AvatarIcon/AvatarIcon.stories.tsx b/app/component-library/components/AvatarIcon/AvatarIcon.stories.tsx new file mode 100644 index 00000000000..9844a991ffb --- /dev/null +++ b/app/component-library/components/AvatarIcon/AvatarIcon.stories.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react-native'; +import { select } from '@storybook/addon-knobs'; +import { BaseAvatarSize } from '../BaseAvatar'; +import { IconName } from '../Icon'; +import AvatarIcon from './AvatarIcon'; + +storiesOf(' Component Library / AvatarIcon', module) + .addDecorator((getStory) => getStory()) + .add('Default', () => { + const groupId = 'Props'; + const sizeSelector = select( + 'size', + BaseAvatarSize, + BaseAvatarSize.Md, + groupId, + ); + const iconSelector = select('name', IconName, IconName.LockFilled, groupId); + + return ; + }); diff --git a/app/component-library/components/AvatarIcon/AvatarIcon.styles.ts b/app/component-library/components/AvatarIcon/AvatarIcon.styles.ts new file mode 100644 index 00000000000..e64345ce75b --- /dev/null +++ b/app/component-library/components/AvatarIcon/AvatarIcon.styles.ts @@ -0,0 +1,35 @@ +import { StyleSheet, ViewStyle } from 'react-native'; +import { + AvatarIconStyleSheet, + AvatarIconStyleSheetVars, +} from './AvatarIcon.types'; +import { Theme } from '../../../util/theme/models'; + +/** + * Style sheet function for AvatarIcon component. + * + * @param params Style sheet params. + * @param params.theme App theme from ThemeContext. + * @param params.vars Inputs that the style sheet depends on. + * @returns StyleSheet object. + */ +const styleSheet = (params: { + theme: Theme; + vars: AvatarIconStyleSheetVars; +}): AvatarIconStyleSheet => { + const { theme, vars } = params; + const { colors } = theme; + const { style } = vars; + return StyleSheet.create({ + base: Object.assign( + { + backgroundColor: colors.primary.muted, + alignItems: 'center', + justifyContent: 'center', + } as ViewStyle, + style, + ) as ViewStyle, + }); +}; + +export default styleSheet; diff --git a/app/component-library/components/AvatarIcon/AvatarIcon.test.tsx b/app/component-library/components/AvatarIcon/AvatarIcon.test.tsx new file mode 100644 index 00000000000..43c7e838eb3 --- /dev/null +++ b/app/component-library/components/AvatarIcon/AvatarIcon.test.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import AvatarIcon from './AvatarIcon'; +import { BaseAvatarSize } from '../BaseAvatar'; +import { IconName } from '../Icon'; + +describe('AvatarIcon', () => { + it('should render correctly', () => { + const wrapper = shallow( + , + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/app/component-library/components/AvatarIcon/AvatarIcon.tsx b/app/component-library/components/AvatarIcon/AvatarIcon.tsx new file mode 100644 index 00000000000..8f30a4b83b1 --- /dev/null +++ b/app/component-library/components/AvatarIcon/AvatarIcon.tsx @@ -0,0 +1,28 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import { useStyles } from '../../../component-library/hooks'; +import BaseAvatar, { BaseAvatarSize } from '../BaseAvatar'; +import Icon, { IconSize } from '../Icon'; +import stylesheet from './AvatarIcon.styles'; +import { AvatarIconProps, IconSizeByAvatarSize } from './AvatarIcon.types'; + +const iconSizeByAvatarSize: IconSizeByAvatarSize = { + [BaseAvatarSize.Xs]: IconSize.Xs, + [BaseAvatarSize.Sm]: IconSize.Sm, + [BaseAvatarSize.Md]: IconSize.Md, + [BaseAvatarSize.Lg]: IconSize.Lg, + [BaseAvatarSize.Xl]: IconSize.Xl, +}; + +const AvatarIcon = ({ size, icon, style, ...props }: AvatarIconProps) => { + const { styles, theme } = useStyles(stylesheet, { style }); + const iconSize = iconSizeByAvatarSize[size]; + + return ( + + + + ); +}; + +export default AvatarIcon; diff --git a/app/component-library/components/AvatarIcon/AvatarIcon.types.ts b/app/component-library/components/AvatarIcon/AvatarIcon.types.ts new file mode 100644 index 00000000000..15c15aa11df --- /dev/null +++ b/app/component-library/components/AvatarIcon/AvatarIcon.types.ts @@ -0,0 +1,35 @@ +import { ViewStyle } from 'react-native'; +import { + BaseAvatarProps, + BaseAvatarSize, +} from '../BaseAvatar/BaseAvatar.types'; +import { IconProps, IconSize } from '../Icon/Icon.types'; + +/** + * AvatarIcon component props. + */ +export interface AvatarIconProps extends BaseAvatarProps { + /** + * Icon to use. + */ + icon: IconProps['name']; +} + +/** + * AvatarIcon component style sheet. + */ +export interface AvatarIconStyleSheet { + base: ViewStyle; +} + +/** + * Style sheet input parameters. + */ +export type AvatarIconStyleSheetVars = Pick; + +/** + * Mapping of IconSize by BaseAvatarSize. + */ +export type IconSizeByAvatarSize = { + [key in BaseAvatarSize]: IconSize; +}; diff --git a/app/component-library/components/AvatarIcon/README.md b/app/component-library/components/AvatarIcon/README.md new file mode 100644 index 00000000000..7be2c200d31 --- /dev/null +++ b/app/component-library/components/AvatarIcon/README.md @@ -0,0 +1,23 @@ +# AvatarIcon + +AvatarIcon is a component that renders an icon contained within an avatar. This component is based on the [BaseAvatar](../BaseAvatar/BaseAvatar.tsx) component. + +## Props + +This component extends [BaseAvatarProps](../BaseAvatar/BaseAvatar.types.ts#L17) from `BaseAvatar` component. + +### `size` + +Enum to select between size variants. + +| TYPE | REQUIRED | +| :----------------------------------------------------- | :------------------------------------------------------ | +| [BaseAvatarSize](../BaseAvatar/BaseAvatar.types.ts#L6) | Yes | + +### `icon` + +Icon to use. + +| TYPE | REQUIRED | +| :-------------------------------------------------- | :------------------------------------------------------ | +| [IconName](../Icon/Icon.types.ts#L53) | Yes | diff --git a/app/component-library/components/AvatarIcon/__snapshots__/AvatarIcon.test.tsx.snap b/app/component-library/components/AvatarIcon/__snapshots__/AvatarIcon.test.tsx.snap new file mode 100644 index 00000000000..6b5d506c39a --- /dev/null +++ b/app/component-library/components/AvatarIcon/__snapshots__/AvatarIcon.test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AvatarIcon should render correctly 1`] = ` + + + +`; diff --git a/app/component-library/components/AvatarIcon/index.ts b/app/component-library/components/AvatarIcon/index.ts new file mode 100644 index 00000000000..72121956681 --- /dev/null +++ b/app/component-library/components/AvatarIcon/index.ts @@ -0,0 +1 @@ +export { default } from './AvatarIcon'; diff --git a/app/component-library/components/IconButton/IconButton.stories.tsx b/app/component-library/components/IconButton/IconButton.stories.tsx new file mode 100644 index 00000000000..16d32a253e1 --- /dev/null +++ b/app/component-library/components/IconButton/IconButton.stories.tsx @@ -0,0 +1,30 @@ +/* eslint-disable no-console */ +import React from 'react'; +import { storiesOf } from '@storybook/react-native'; +import { select, boolean } from '@storybook/addon-knobs'; +import { IconName } from '../Icon'; +import IconButton from './IconButton'; +import { IconButtonVariant } from './IconButton.types'; + +storiesOf(' Component Library / IconButton', module) + .addDecorator((getStory) => getStory()) + .add('Default', () => { + const groupId = 'Props'; + const iconSelector = select('icon', IconName, IconName.LockFilled, groupId); + const variantSelector = select( + 'variant', + IconButtonVariant, + IconButtonVariant.Primary, + groupId, + ); + const disabledSelector = boolean('disabled', false, groupId); + + return ( + console.log("I'm clicked!")} + /> + ); + }); diff --git a/app/component-library/components/IconButton/IconButton.styles.ts b/app/component-library/components/IconButton/IconButton.styles.ts new file mode 100644 index 00000000000..610a1d52380 --- /dev/null +++ b/app/component-library/components/IconButton/IconButton.styles.ts @@ -0,0 +1,32 @@ +import { StyleSheet, ViewStyle } from 'react-native'; +import { + IconButtonStyleSheet, + IconButtonStyleSheetVars, +} from './IconButton.types'; + +/** + * Style sheet function for IconButton component. + * + * @param params Style sheet params. + * @param params.vars Inputs that the style sheet depends on. + * @returns StyleSheet object. + */ +const styleSheet = (params: { + vars: IconButtonStyleSheetVars; +}): IconButtonStyleSheet => { + const { vars } = params; + const { style } = vars; + return StyleSheet.create({ + base: Object.assign( + { + alignItems: 'center', + justifyContent: 'center', + height: 32, + width: 32, + } as ViewStyle, + style, + ) as ViewStyle, + }); +}; + +export default styleSheet; diff --git a/app/component-library/components/IconButton/IconButton.test.tsx b/app/component-library/components/IconButton/IconButton.test.tsx new file mode 100644 index 00000000000..21bd6baf5b1 --- /dev/null +++ b/app/component-library/components/IconButton/IconButton.test.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { IconName } from '../Icon'; +import IconButton from './IconButton'; +import { IconButtonVariant } from './IconButton.types'; + +describe('IconButton', () => { + it('should render correctly', () => { + const wrapper = shallow( + , + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/app/component-library/components/IconButton/IconButton.tsx b/app/component-library/components/IconButton/IconButton.tsx new file mode 100644 index 00000000000..da31286cf9f --- /dev/null +++ b/app/component-library/components/IconButton/IconButton.tsx @@ -0,0 +1,69 @@ +/* eslint-disable react/prop-types */ +import React, { useCallback, useMemo, useState } from 'react'; +import { GestureResponderEvent, TouchableOpacity } from 'react-native'; +import { useStyles } from '../../../component-library/hooks'; +import Icon, { IconSize } from '../Icon'; +import stylesheet from './IconButton.styles'; +import { IconButtonProps, IconButtonVariant } from './IconButton.types'; + +const IconButton = ({ + icon, + variant, + disabled, + onPressIn, + onPressOut, + style, + ...props +}: IconButtonProps) => { + const { + styles, + theme: { colors }, + } = useStyles(stylesheet, { style }); + const [pressed, setPressed] = useState(false); + const iconColor = useMemo(() => { + let color: string; + if (disabled) { + color = colors.icon.muted; + } else { + switch (variant) { + case IconButtonVariant.Primary: + color = pressed ? colors.primary.alternative : colors.primary.default; + break; + case IconButtonVariant.Secondary: + color = pressed ? colors.icon.alternative : colors.icon.default; + break; + } + } + return color; + }, [colors, variant, disabled, pressed]); + + const triggerOnPressedIn = useCallback( + (e: GestureResponderEvent) => { + setPressed(true); + onPressIn?.(e); + }, + [setPressed, onPressIn], + ); + + const triggerOnPressedOut = useCallback( + (e: GestureResponderEvent) => { + setPressed(false); + onPressOut?.(e); + }, + [setPressed, onPressOut], + ); + + return ( + + + + ); +}; + +export default IconButton; diff --git a/app/component-library/components/IconButton/IconButton.types.ts b/app/component-library/components/IconButton/IconButton.types.ts new file mode 100644 index 00000000000..6895c6d08fd --- /dev/null +++ b/app/component-library/components/IconButton/IconButton.types.ts @@ -0,0 +1,37 @@ +import { TouchableOpacityProps, ViewStyle } from 'react-native'; +import { IconProps } from '../Icon/Icon.types'; + +export enum IconButtonVariant { + Primary = 'Primary', + Secondary = 'Secondary', +} + +/** + * IconButton component props. + */ +export interface IconButtonProps extends TouchableOpacityProps { + /** + * Icon to use. + */ + icon: IconProps['name']; + /** + * Function to trigger when pressed. + */ + onPress: () => void; + /** + * Enum to select between variants. + */ + variant: IconButtonVariant; +} + +/** + * IconButton component style sheet. + */ +export interface IconButtonStyleSheet { + base: ViewStyle; +} + +/** + * Style sheet input parameters. + */ +export type IconButtonStyleSheetVars = Pick; diff --git a/app/component-library/components/IconButton/README.md b/app/component-library/components/IconButton/README.md new file mode 100644 index 00000000000..53fda7e82a5 --- /dev/null +++ b/app/component-library/components/IconButton/README.md @@ -0,0 +1,31 @@ +# IconButton + +IconButton is a icon component in the form of a button. + +## Props + +This component extends `TouchableOpacityProps` from React Native's [TouchableOpacity Component](https://reactnative.dev/docs/touchableOpacity). + +### `icon` + +Icon to use. + +| TYPE | REQUIRED | +| :-------------------------------------------------- | :------------------------------------------------------ | +| [IconName](../Icon/Icon.types.ts#L53) | Yes | + +### `onPress` + +Function to trigger when pressed. + +| TYPE | REQUIRED | +| :-------------------------------------------------- | :------------------------------------------------------ | +| function | Yes | + +### `variant` + +Enum to select between variants. + +| TYPE | REQUIRED | +| :-------------------------------------------------- | :------------------------------------------------------ | +| [IconButtonVariant](./IconButton.types.ts#L4) | Yes | diff --git a/app/component-library/components/IconButton/__snapshots__/IconButton.test.tsx.snap b/app/component-library/components/IconButton/__snapshots__/IconButton.test.tsx.snap new file mode 100644 index 00000000000..59cc5a38217 --- /dev/null +++ b/app/component-library/components/IconButton/__snapshots__/IconButton.test.tsx.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IconButton should render correctly 1`] = ` + + + +`; diff --git a/app/component-library/components/IconButton/index.ts b/app/component-library/components/IconButton/index.ts new file mode 100644 index 00000000000..e27bf89f75c --- /dev/null +++ b/app/component-library/components/IconButton/index.ts @@ -0,0 +1,2 @@ +export { default } from './IconButton'; +export { IconButtonVariant } from './IconButton.types'; diff --git a/storybook/storyLoader.js b/storybook/storyLoader.js index 8c44a3e0e91..be6cbb6f957 100644 --- a/storybook/storyLoader.js +++ b/storybook/storyLoader.js @@ -12,6 +12,7 @@ function loadStories() { require('../app/components/UI/Fox/Fox.stories'); require('../app/components/UI/StyledButton/StyledButton.stories'); require('../app/component-library/components/AccountAvatar/AccountAvatar.stories'); + require('../app/component-library/components/AvatarIcon/AvatarIcon.stories'); require('../app/component-library/components/BaseAvatar/BaseAvatar.stories'); require('../app/component-library/components/BaseButton/BaseButton.stories'); require('../app/component-library/components/ButtonPrimary/ButtonPrimary.stories'); @@ -19,6 +20,7 @@ function loadStories() { require('../app/component-library/components/ButtonTertiary/ButtonTertiary.stories'); require('../app/component-library/components/BaseText/BaseText.stories'); require('../app/component-library/components/Icon/Icon.stories'); + require('../app/component-library/components/IconButton/IconButton.stories'); require('../app/component-library/components/Link/Link.stories'); require('../app/component-library/components/NetworkAvatar/NetworkAvatar.stories'); require('../app/component-library/components/FaviconAvatar/FaviconAvatar.stories'); @@ -34,6 +36,7 @@ const stories = [ '../app/components/UI/Fox/Fox.stories', '../app/components/UI/StyledButton/StyledButton.stories', '../app/component-library/components/AccountAvatar/AccountAvatar.stories', + '../app/component-library/components/AvatarIcon/AvatarIcon.stories', '../app/component-library/components/BaseAvatar/BaseAvatar.stories', '../app/component-library/components/BaseButton/BaseButton.stories', '../app/component-library/components/ButtonPrimary/ButtonPrimary.stories', @@ -41,6 +44,7 @@ const stories = [ '../app/component-library/components/ButtonTertiary/ButtonTertiary.stories', '../app/component-library/components/BaseText/BaseText.stories', '../app/component-library/components/Icon/Icon.stories', + '../app/component-library/components/IconButton/IconButton.stories', '../app/component-library/components/Link/Link.stories', '../app/component-library/components/NetworkAvatar/NetworkAvatar.stories', '../app/component-library/components/FaviconAvatar/FaviconAvatar.stories',