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')
+ })
+})