Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# CLAUDE.md

Guidance for Claude Code when working in this repository.

## Keep the README in sync with src/index.ts

`src/index.ts` is the public entry point of this component library (plus the
`react-native-components/theme` and `react-native-components/styles` subpath
exports). The README's component lists are a hand-maintained mirror of these
exports, not generated.

Whenever a component, hook, or utility is added to, removed from, or renamed
in `src/index.ts` (or the `theme`/`styles` subpath exports), update the
corresponding list in [README.md](README.md) in the same change.
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,34 @@ UI components:
- `BackButton`
- `BackgroundImage`
- `BottomSheet`
- `BottomSheetWithSearchInput`
- `BrandIcon`
- `BrandIcons`
- `CapitalizeText`
- `Checkbox`
- `Chip`
- `ContentImage`
- `Date`
- `DateFromToFilter`
- `DatePicker`
- `Disclose`
- `DocumentLink`
- `FilterButton`
- `FilterOption`
- `FilterSelectField`
- `HtmlContent (+ HtmlContentProps)`
- `Icon`
- `IconButton`
- `IconName`
- `IconText`
- `IconView`
- `Icons`
- `InputField`
- `InputPanel`
- `ItemSeparator`
- `LargeButton (+ LargeButtonProps)`
- `Icon/Icons`
- `Lightbox`
- `ListItem`
- `Location`
- `Message`
- `MoreInfo`
Expand All @@ -33,16 +46,30 @@ UI components:
- `Popup`
- `ProgressBar`
- `ProgressBarList`
- `Rarity`
- `RaritySlider`
- `SectionHeader`
- `SingleLine`
- `TextLink`
- `Tooltip`
- `TooltipProps`
- `Tooltip (+ TooltipProps)`
- `WebLink`

non-UI components:

- `Log.setLogConfiguration`: A function to change the logging of the component library
- `openUrl`: Opens URLs
- `useBottomSheetBackHandler`: A hook to close a BottomSheet on the Android hardware back button
- `useShowBlurView`: A safe way to set a blur on the background
- `theme`: A default theme with color and margins
- `font`: A set of font styles
- `text`: A set of text styles

Additionally, the package exposes two subpath entry points:

`react-native-components/theme`:

- `ThemeProvider`, `useTheme`, `useStyles`
- `createTheme`, `defaultTheme`: The default theme with colors, margins and text styles

`react-native-components/styles`:

- `font`, `fontSize`, `lineHeight`: Font styles
- `layout`, `rounded`, `shadow`: Layout styles
- `createInputStyles`, `createBottomSheetStyles`
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@observation.org/react-native-components",
"version": "1.97.0",
"version": "1.98.0",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
Expand Down
160 changes: 160 additions & 0 deletions src/components/HtmlContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import React, { useMemo } from 'react'
import { Dimensions, GestureResponderEvent, Text, View } from 'react-native'

import RenderHtml, {
CustomRendererProps,
MixedStyleDeclaration,
RenderHTMLProps,
TBlock,
defaultSystemFonts,
} from 'react-native-render-html'

import ContentImage from './ContentImage'
import Log from '../lib/Log'
import { openUrl } from '../lib/Url'
import { useTheme } from '../theme'

type HtmlContentProps =
| ({ html: string; source?: never } & Omit<RenderHTMLProps, 'source'>)
| ({ source: RenderHTMLProps['source']; html?: never } & Omit<RenderHTMLProps, 'source'>)

const useHtmlStyles = (): Readonly<Record<string, MixedStyleDeclaration>> => {
Log.trace('HtmlContent:useHtmlStyles')
const theme = useTheme()
return {
h1: {
...(theme.text.title as MixedStyleDeclaration),
marginTop: 0,
marginBottom: 0,
},

h2: {
...(theme.text.subtitle as MixedStyleDeclaration),
marginTop: theme.margin.common,
marginBottom: 0,
},

h3: {
...(theme.text.lead as MixedStyleDeclaration),
marginTop: theme.margin.common,
marginBottom: 0,
},

ul: {
marginTop: theme.margin.common,
paddingLeft: theme.margin.common,
marginBottom: theme.margin.half,
},

ol: {
marginTop: theme.margin.common,
paddingLeft: theme.margin.common,
marginBottom: theme.margin.half,
},

li: {
marginBottom: theme.margin.half,
},

a: {
color: theme.color.text.system.link,
textDecorationLine: 'none',
},
}
}

const ImgRenderer = ({ tnode }: { tnode: TBlock }) => {
Log.trace('HtmlContent:ImgRenderer')
const { src, alt } = tnode.attributes
if (!src) {
return null
}
return <ContentImage key={src} alt={alt} src={src} />
}

const OlRenderer = ({ InternalRenderer, ...props }: CustomRendererProps<TBlock>) => {
Log.trace('HtmlContent:OlRenderer')
const theme = useTheme()
return (
<InternalRenderer
{...props}
style={{
...props.style,
paddingLeft: theme.margin.large,
}}
/>
)
}

const UlRenderer = ({ TNodeChildrenRenderer, ...props }: CustomRendererProps<TBlock>) => {
Log.trace('HtmlContent:UlRenderer')
const theme = useTheme()
return (
<>
{props.tnode.children.map((item) => (
<View key={item.nodeIndex} style={{ flexDirection: 'row' }}>
<Text style={{ marginRight: theme.margin.quarter, ...theme.text.body }}>•</Text>
<TNodeChildrenRenderer tnode={item} />
</View>
))}
</>
)
}

const defaultRenderers = {
img: ImgRenderer,
ol: OlRenderer,
ul: UlRenderer,
}

const defaultRenderersProps = {
a: {
onPress: (_event: GestureResponderEvent, href: string) =>
openUrl(href).catch(() => {
Log.warn('HtmlContent:onPress could not open url', href)
}),
},
}

const systemFonts = [...defaultSystemFonts, 'Ubuntu']

const HtmlContent = ({
html,
source,
contentWidth,
renderers: customRenderers = {},
renderersProps: customRenderersProps = {},
baseStyle,
...restProps
}: HtmlContentProps) => {
Log.trace('HtmlContent')
const theme = useTheme()
const resolvedBaseStyle = baseStyle ?? (theme.text.body as MixedStyleDeclaration)
const htmlStyles = useHtmlStyles()

const resolvedContentWidth = contentWidth ?? Dimensions.get('window').width - 2 * theme.margin.double

const resolvedSource = useMemo(() => source ?? { html }, [source, html])
const mergedRenderers = useMemo(() => ({ ...defaultRenderers, ...customRenderers }), [customRenderers])
const mergedRenderersProps = useMemo(
() => ({ ...defaultRenderersProps, ...customRenderersProps }),
[customRenderersProps],
)
return (
<RenderHtml
source={resolvedSource}
contentWidth={resolvedContentWidth}
renderersProps={mergedRenderersProps}
renderers={mergedRenderers}
systemFonts={systemFonts}
baseStyle={resolvedBaseStyle}
tagsStyles={htmlStyles}
enableExperimentalMarginCollapsing
{...restProps}
/>
)
}

const MemoHtmlContent = React.memo(HtmlContent)
export default MemoHtmlContent
export type { HtmlContentProps }
72 changes: 72 additions & 0 deletions src/components/__tests__/HtmlContent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react'

import { describe, expect, test } from '@jest/globals'
import { render } from '@testing-library/react-native'

import HtmlContent from '../HtmlContent'

describe('HtmlContent', () => {
describe('Rendering', () => {
describe('Custom image renderer', () => {
test('Normal render', () => {
const html = `<img src="https://waarneming.nl/image.jpeg" alt="A picture" />`
const { toJSON } = render(<HtmlContent html={html} />)
expect(toJSON()).toMatchSnapshot()
})

test('Without url', () => {
const html = `<img alt="A picture" />`
const { toJSON } = render(<HtmlContent html={html} />)
expect(toJSON()).toMatchSnapshot()
})
})

describe('Override renderer', () => {
test('Override ol', () => {
const html = '<ol>\n<li>Ein</li>\n<li>Zwei</li>\n<li>Drei</li>\n</ol>'
const renderers = { ol: () => <></> }
const { toJSON, queryByText } = render(<HtmlContent html={html} renderers={renderers} />)
expect(queryByText('Ein')).toBeFalsy()
expect(toJSON()).toMatchSnapshot()
})

test('Override ul does not affect ol', () => {
const html = '<ol>\n<li>Ein</li>\n<li>Zwei</li>\n<li>Drei</li>\n</ol>'
const renderers = { ul: () => <></> }
const { toJSON, queryByText } = render(<HtmlContent html={html} renderers={renderers} />)
expect(queryByText('Ein')).toBeTruthy()
expect(toJSON()).toMatchSnapshot()
})
})

describe('Custom ordered list renderer', () => {
test('Normal renderer', () => {
const html = '<ol>\n<li>Ein</li>\n<li>Zwei</li>\n<li>Drei</li>\n</ol>'
const { toJSON } = render(<HtmlContent html={html} />)
expect(toJSON()).toMatchSnapshot()
})

test('Ordered list inside an unordered list', () => {
const html =
'<ul>\n<li>Ein<ol>\n<li>Ein</li>\n<li>Zwei</li>\n<li>Drei</li>\n</ol>\n</li>\n<li>Zwei</li>\n<li>Drei</li>\n</ul>'
const { toJSON } = render(<HtmlContent html={html} />)
expect(toJSON()).toMatchSnapshot()
})

test('Unordered list inside an ordered list', () => {
const html =
'<ol>\n<li>Ein<ul>\n<li>Ein</li>\n<li>Zwei</li>\n<li>Drei</li>\n</ul>\n</li>\n<li>Zwei</li>\n<li>Drei</li>\n</ol>'
const { toJSON } = render(<HtmlContent html={html} />)
expect(toJSON()).toMatchSnapshot()
})
})

describe('Custom unordered list', () => {
test('Normal render', () => {
const html = '<ul>\n<li>Ein</li>\n<li>Zwei</li>\n<li>Drei</li>\n</ul>'
const { toJSON } = render(<HtmlContent html={html} />)
expect(toJSON()).toMatchSnapshot()
})
})
})
})
Loading