diff --git a/package.json b/package.json index de9d87b..ab11023 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@observation.org/react-native-components", - "version": "1.78.0", + "version": "1.79.0", "main": "src/index.ts", "exports": { ".": "./src/index.ts", diff --git a/src/components/CapitalizeText.tsx b/src/components/CapitalizeText.tsx new file mode 100644 index 0000000..0df997e --- /dev/null +++ b/src/components/CapitalizeText.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Text, TextProps } from 'react-native' + +import { capitalize } from '../lib/Utils' + +/** + * Renders the children, capitalizing the first child when this is a string + */ +const CapitalizeText = ({ children, ...props }: TextProps) => ( + + {React.Children.map(children, (child, index) => + index === 0 && typeof child === 'string' ? capitalize(child) : child, + )} + +) + +export default CapitalizeText diff --git a/src/components/InputPanel.tsx b/src/components/InputPanel.tsx new file mode 100644 index 0000000..f458bab --- /dev/null +++ b/src/components/InputPanel.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import { StyleProp, StyleSheet, Text, TextStyle, TouchableOpacity, View, ViewStyle } from 'react-native' + +import CapitalizeText from './CapitalizeText' +import { Theme, useStyles, useTheme } from '../theme' +import { Icon } from './Icon' + +type Props = { + label?: string + value?: string + onPress: () => void + containerStyle?: StyleProp + valueStyle?: StyleProp + disabled?: boolean + capitalize?: boolean + showChevron?: boolean +} + +const InputPanel = ({ + label, + value, + containerStyle, + valueStyle, + onPress, + disabled = false, + capitalize = false, + showChevron = true, +}: Props) => { + const theme = useTheme() + const styles = useStyles(createStyles) + + // TODO: 48 and 36 should be input heights and we should make them dynamically themed + const paddingVertical = label + ? (48 - (styles.headerTextStyle.lineHeight! + styles.value.lineHeight!)) / 2 + : (36 - styles.value.lineHeight!) / 2 + + return ( + + + {label && ( + + {label} + + )} + {capitalize && value ? ( + + {value} + + ) : ( + + {value || ' '} + + )} + + {showChevron && ( + + + + )} + + ) +} + +export default InputPanel + +const createStyles = (theme: Theme) => + StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: theme.margin.common, + }, + headerTextStyle: { + ...theme.font.extraSmall, + lineHeight: theme.font.extraSmall.fontSize, + letterSpacing: 0.03 * theme.font.extraSmall.fontSize, + color: theme.color.text.system.subtler, + }, + contentContainer: { + flex: 1, + flexDirection: 'column', + }, + value: { + ...theme.font.medium, + color: theme.color.text.system.strong, + }, + icon: { + marginLeft: theme.margin.half, + }, + }) diff --git a/src/components/ItemSeparator.tsx b/src/components/ItemSeparator.tsx new file mode 100644 index 0000000..c739f95 --- /dev/null +++ b/src/components/ItemSeparator.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native' + +import { Theme, useStyles } from '../theme' + +type Orientation = 'horizontal' | 'vertical' + +type Props = { + style?: StyleProp + orientation?: Orientation +} + +const ItemSeparator = ({ style, orientation = 'horizontal' }: Props) => { + const styles = useStyles(createStyles) + const separatorStyle = orientation === 'horizontal' ? styles.horizontalSeparator : styles.verticalSeparator + return +} + +export default ItemSeparator + +const createStyles = (theme: Theme) => + StyleSheet.create({ + horizontalSeparator: { + borderBottomWidth: 1, + borderBottomColor: theme.color.background.system.surfaceRaised, + }, + verticalSeparator: { + borderRightWidth: 1, + borderRightColor: theme.color.background.system.surfaceRaised, + }, + }) diff --git a/src/components/ListItem.tsx b/src/components/ListItem.tsx new file mode 100644 index 0000000..057d8fb --- /dev/null +++ b/src/components/ListItem.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import { StyleSheet, TouchableOpacity, View } from 'react-native' + +import SingleLine from './SingleLine' +import { Theme, useStyles, useTheme } from '../theme' +import { Icon, IconProps } from './Icon' + +type Props = { + icon?: IconProps + label: string + subLabel?: string + extraSubLabel?: string + onPress?: () => void + selected?: boolean +} + +const ListItem = ({ icon, onPress, label, subLabel, extraSubLabel, selected = false }: Props) => { + const theme = useTheme() + const styles = useStyles(createStyles) + + const iconMarginRight = subLabel ? theme.margin.common : theme.margin.half + const containerPaddingVertical = subLabel ? 7 : 13 + const containerBackgroundColor = selected ? theme.color.primary50 : undefined + + const renderIcon = (() => { + switch (true) { + case !!icon: + return ( + + ) + case selected: + return ( + + ) + default: + return ( + + ) + } + })() + + return ( + + + {renderIcon} + + {label} + {subLabel && ( + + {subLabel} + {extraSubLabel && {extraSubLabel}} + + )} + + + + ) +} + +export default ListItem + +const createStyles = (theme: Theme) => + StyleSheet.create({ + containerStyle: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: theme.margin.common, + }, + labelTextStyle: { + ...theme.font.medium, + lineHeight: theme.lineHeight.small, + color: theme.color.text.system.strong, + }, + subLabelTextStyle: { + ...theme.font.extraSmall, + color: theme.color.text.system.subtler, + }, + }) diff --git a/src/components/SectionHeader.tsx b/src/components/SectionHeader.tsx new file mode 100644 index 0000000..f944f7a --- /dev/null +++ b/src/components/SectionHeader.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native' + +import { Theme, useStyles } from '../theme' + +type Props = { + title?: string + containerStyle?: StyleProp +} + +const SectionHeader = ({ title, containerStyle }: Props) => { + const styles = useStyles(createStyles) + return ( + + {title && {title}} + + ) +} + +export default SectionHeader + +const createStyles = (theme: Theme) => + StyleSheet.create({ + header: { + paddingHorizontal: theme.margin.common, + marginTop: theme.margin.common, + }, + headerPadding: { + paddingVertical: theme.margin.quarter, + }, + contentHeaderStyle: { + ...theme.font.smallBold, + color: theme.color.text.system.subtler, + }, + }) diff --git a/src/components/SingleLine.tsx b/src/components/SingleLine.tsx new file mode 100644 index 0000000..0653058 --- /dev/null +++ b/src/components/SingleLine.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { StyleProp, Text, TextStyle } from 'react-native' + +type Props = { + children?: React.ReactNode + style?: StyleProp +} + +const SingleLine = ({ children, style }: Props) => ( + + {children} + +) + +export default SingleLine diff --git a/src/components/__tests__/CapitalizeText.test.tsx b/src/components/__tests__/CapitalizeText.test.tsx new file mode 100644 index 0000000..c53be81 --- /dev/null +++ b/src/components/__tests__/CapitalizeText.test.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { Text } from 'react-native' + +import { describe, expect, test } from '@jest/globals' +import { render } from '@testing-library/react-native' + +import CapitalizeText from '../CapitalizeText' + +describe('CapitalizeText', () => { + test('Capitalizes the text', () => { + const { queryByText } = render(hello world) + expect(queryByText('Hello world')).toBeTruthy() + }) + + test('With multiple children, the first one being a string, the text is capitalized', () => { + const { queryByText } = render( + + hello world + , + ) + expect(queryByText('Hello world')).toBeTruthy() + }) + + test('When the first child is not a string, no text is capitalized', () => { + const { queryByText } = render( + + hello world + , + ) + expect(queryByText('hello world')).toBeTruthy() + }) +}) diff --git a/src/components/__tests__/InputPanel.test.tsx b/src/components/__tests__/InputPanel.test.tsx new file mode 100644 index 0000000..632d909 --- /dev/null +++ b/src/components/__tests__/InputPanel.test.tsx @@ -0,0 +1,80 @@ +import React from 'react' + +import { describe, expect, jest, test } from '@jest/globals' +import { fireEvent, render } from '@testing-library/react-native' + +import { color } from '../../theme/tokens/color' +import InputPanel from '../InputPanel' + +describe('InputPanel', () => { + const onPress = jest.fn() + + describe('Rendering', () => { + test('With content', () => { + const { toJSON } = render() + + expect(toJSON()).toMatchSnapshot() + }) + + test('No label', () => { + const { toJSON } = render() + + expect(toJSON()).toMatchSnapshot() + }) + + test('No value', () => { + const { toJSON } = render() + + expect(toJSON()).toMatchSnapshot() + }) + + test('Disabled', () => { + const { toJSON } = render() + + expect(toJSON()).toMatchSnapshot() + }) + + test('Capitalize value', () => { + const { toJSON, queryByText } = render( + , + ) + + expect(queryByText('Seen')).toBeTruthy() + expect(toJSON()).toMatchSnapshot() + }) + + test('Without chevron', () => { + const { toJSON } = render( + , + ) + + expect(toJSON()).toMatchSnapshot() + }) + + test('With value style', () => { + const { toJSON } = render( + , + ) + + expect(toJSON()).toMatchSnapshot() + }) + }) + + describe('Interaction', () => { + test('Click', async () => { + // GIVEN + const { getByText } = render() + + // WHEN + await fireEvent.press(getByText('Counting method')) + + // THEN + expect(onPress).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/components/__tests__/ItemSeparator.test.tsx b/src/components/__tests__/ItemSeparator.test.tsx new file mode 100644 index 0000000..21773b8 --- /dev/null +++ b/src/components/__tests__/ItemSeparator.test.tsx @@ -0,0 +1,33 @@ +import React from 'react' + +import { describe, expect, test } from '@jest/globals' +import { render } from '@testing-library/react-native' + +import { margin } from '../../theme/tokens/margin' +import ItemSeparator from '../ItemSeparator' + +describe('ItemSeparator', () => { + test('Rendering default separator', () => { + // WHEN + const { toJSON } = render() + + // THEN + expect(toJSON()).toMatchSnapshot() + }) + + test('Rendering vertical separator', () => { + // WHEN + const { toJSON } = render() + + // THEN + expect(toJSON()).toMatchSnapshot() + }) + + test('Rendering with custom style separator', () => { + // WHEN + const { toJSON } = render() + + // THEN + expect(toJSON()).toMatchSnapshot() + }) +}) diff --git a/src/components/__tests__/ListItem.test.tsx b/src/components/__tests__/ListItem.test.tsx new file mode 100644 index 0000000..a6a66ff --- /dev/null +++ b/src/components/__tests__/ListItem.test.tsx @@ -0,0 +1,68 @@ +import React from 'react' + +import { describe, expect, jest, test } from '@jest/globals' +import { fireEvent, render } from '@testing-library/react-native' + +import { color } from '../../theme/tokens/color' +import { IconProps } from '../Icon' +import ListItem from '../ListItem' + +const onPress = jest.fn() + +describe('ListItem', () => { + describe('Rendering', () => { + test('Rendering with radio button unselected', () => { + // WHEN + const { toJSON } = render() + + // THEN + expect(toJSON()).toMatchSnapshot() + }) + + test('Rendering with radio button selected', () => { + // WHEN + const { toJSON } = render() + + // THEN + expect(toJSON()).toMatchSnapshot() + }) + + test('Rendering with sub label', () => { + // WHEN + const { toJSON } = render() + + // THEN + expect(toJSON()).toMatchSnapshot() + }) + + test('Rendering with extra sub label', () => { + // WHEN + const { toJSON } = render( + , + ) + + // THEN + expect(toJSON()).toMatchSnapshot() + }) + + test('Rendering with custom icon', () => { + // WHEN + const icon: IconProps = { name: 'check', color: color.grey300, size: 20, style: 'light' } + const { toJSON } = render() + + // THEN + expect(toJSON()).toMatchSnapshot() + }) + }) + + test('Interaction', () => { + // GIVEN + const { getByText } = render() + + // WHEN + fireEvent.press(getByText('Read this!')) + + // THEN + expect(onPress).toHaveBeenCalled() + }) +}) diff --git a/src/components/__tests__/SectionHeader.test.tsx b/src/components/__tests__/SectionHeader.test.tsx new file mode 100644 index 0000000..2c0da49 --- /dev/null +++ b/src/components/__tests__/SectionHeader.test.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +import { describe, expect, test } from '@jest/globals' +import { render } from '@testing-library/react-native' + +import SectionHeader from '../SectionHeader' + +describe('SectionHeader', () => { + describe('Rendering', () => { + test('Normal', () => { + // WHEN + const { toJSON } = render() + + // THEN + expect(toJSON()).toMatchSnapshot() + }) + + test('With container style', () => { + // WHEN + const { toJSON } = render() + + // THEN + expect(toJSON()).toMatchSnapshot() + }) + }) +}) diff --git a/src/components/__tests__/SingleLine.test.tsx b/src/components/__tests__/SingleLine.test.tsx new file mode 100644 index 0000000..7800d59 --- /dev/null +++ b/src/components/__tests__/SingleLine.test.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +import { describe, expect, test } from '@jest/globals' +import { render } from '@testing-library/react-native' + +import SingleLine from '../SingleLine' + +describe('SingleLine', () => { + test('Render', () => { + const { toJSON } = render(hello world) + expect(toJSON()).toMatchSnapshot() + }) +}) diff --git a/src/components/__tests__/__snapshots__/InputPanel.test.tsx.snap b/src/components/__tests__/__snapshots__/InputPanel.test.tsx.snap new file mode 100644 index 0000000..04086f8 --- /dev/null +++ b/src/components/__tests__/__snapshots__/InputPanel.test.tsx.snap @@ -0,0 +1,714 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`InputPanel Rendering Capitalize value 1`] = ` + + + + Counting method + + + Seen + + + + + + +`; + +exports[`InputPanel Rendering Disabled 1`] = ` + + + + Counting method + + + seen + + + + + + +`; + +exports[`InputPanel Rendering No label 1`] = ` + + + + Netherlands + + + + + + +`; + +exports[`InputPanel Rendering No value 1`] = ` + + + + Counting method + + + + + + + + + +`; + +exports[`InputPanel Rendering With content 1`] = ` + + + + Counting method + + + seen + + + + + + +`; + +exports[`InputPanel Rendering With value style 1`] = ` + + + + Counting method + + + seen + + + + + + +`; + +exports[`InputPanel Rendering Without chevron 1`] = ` + + + + Counting method + + + seen + + + +`; diff --git a/src/components/__tests__/__snapshots__/ItemSeparator.test.tsx.snap b/src/components/__tests__/__snapshots__/ItemSeparator.test.tsx.snap new file mode 100644 index 0000000..e74a4e1 --- /dev/null +++ b/src/components/__tests__/__snapshots__/ItemSeparator.test.tsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`ItemSeparator Rendering default separator 1`] = ` + +`; + +exports[`ItemSeparator Rendering vertical separator 1`] = ` + +`; + +exports[`ItemSeparator Rendering with custom style separator 1`] = ` + +`; diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap new file mode 100644 index 0000000..2ddf162 --- /dev/null +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -0,0 +1,527 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`ListItem Rendering Rendering with custom icon 1`] = ` + + + + + + + + Read this! + + + + +`; + +exports[`ListItem Rendering Rendering with extra sub label 1`] = ` + + + + + + + + Read this! + + + + Really! + + + Or not + + + + + +`; + +exports[`ListItem Rendering Rendering with radio button selected 1`] = ` + + + + + + + + Read this! + + + + +`; + +exports[`ListItem Rendering Rendering with radio button unselected 1`] = ` + + + + + + + + Read this! + + + + +`; + +exports[`ListItem Rendering Rendering with sub label 1`] = ` + + + + + + + + Read this! + + + + Really! + + + + + +`; diff --git a/src/components/__tests__/__snapshots__/SectionHeader.test.tsx.snap b/src/components/__tests__/__snapshots__/SectionHeader.test.tsx.snap new file mode 100644 index 0000000..74df60d --- /dev/null +++ b/src/components/__tests__/__snapshots__/SectionHeader.test.tsx.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`SectionHeader Rendering Normal 1`] = ` + + + Header title + + +`; + +exports[`SectionHeader Rendering With container style 1`] = ` + + + Header title + + +`; diff --git a/src/components/__tests__/__snapshots__/SingleLine.test.tsx.snap b/src/components/__tests__/__snapshots__/SingleLine.test.tsx.snap new file mode 100644 index 0000000..cdb8d3e --- /dev/null +++ b/src/components/__tests__/__snapshots__/SingleLine.test.tsx.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`SingleLine Render 1`] = ` + + hello world + +`; diff --git a/src/index.ts b/src/index.ts index b6de246..b605483 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import Accordion from './components/Accordion' import BackButton from './components/BackButton' import BackgroundImage from './components/BackgroundImage' import BottomSheet from './components/BottomSheet' +import CapitalizeText from './components/CapitalizeText' import Checkbox from './components/Checkbox' import Chip from './components/Chip' import ContentImage from './components/ContentImage' @@ -12,8 +13,11 @@ import IconButton from './components/IconButton' import IconText from './components/IconText' import IconView from './components/IconView' import InputField from './components/InputField' +import InputPanel from './components/InputPanel' +import ItemSeparator from './components/ItemSeparator' import LargeButton, { LargeButtonProps } from './components/LargeButton' import Lightbox from './components/Lightbox' +import ListItem from './components/ListItem' import Location from './components/Location' import Message from './components/Message' import MoreInfo from './components/MoreInfo' @@ -23,6 +27,8 @@ import PageIndicator from './components/PageIndicator' import Panel from './components/Panel' import Popup from './components/Popup' import ProgressBarList from './components/ProgressBarList' +import SectionHeader from './components/SectionHeader' +import SingleLine from './components/SingleLine' import TextLink from './components/TextLink' import Tooltip, { TooltipProps } from './components/Tooltip' import WebLink from './components/WebLink' @@ -39,6 +45,7 @@ export { BackButton, BackgroundImage, BottomSheet, + CapitalizeText, Checkbox, Chip, ContentImage, @@ -51,8 +58,11 @@ export { Icons, BrandIcons, InputField, + InputPanel, + ItemSeparator, LargeButton, Lightbox, + ListItem, Location, Message, MoreInfo, @@ -63,6 +73,8 @@ export { Popup, ProgressBar, ProgressBarList, + SectionHeader, + SingleLine, TextLink, ThemeProvider, Tooltip, diff --git a/src/lib/Utils.ts b/src/lib/Utils.ts index f42b223..7f6a6a2 100644 --- a/src/lib/Utils.ts +++ b/src/lib/Utils.ts @@ -21,3 +21,5 @@ export const deepMerge = (target: T, source: Partial): T => { } return target } + +export const capitalize = (input: string) => input.charAt(0).toUpperCase() + input.slice(1) diff --git a/src/lib/__tests__/Utils.test.ts b/src/lib/__tests__/Utils.test.ts new file mode 100644 index 0000000..514f06e --- /dev/null +++ b/src/lib/__tests__/Utils.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from '@jest/globals' + +import { capitalize } from '../Utils' + +describe('Utils', () => { + test('capitalize', () => { + expect(capitalize('')).toBe('') + expect(capitalize('a')).toBe('A') + expect(capitalize('A')).toBe('A') + expect(capitalize('abc')).toBe('Abc') + expect(capitalize('ABC')).toBe('ABC') + expect(capitalize('aBC')).toBe('ABC') + expect(capitalize('ábc')).toBe('Ábc') + }) +})