From eb6927d7b922165e98fa9b54930fd37f81b316a1 Mon Sep 17 00:00:00 2001 From: Sjaak Schilperoort Date: Fri, 11 Sep 2026 12:20:10 +0200 Subject: [PATCH 1/2] Move HtmlContent to the component library --- package.json | 2 +- src/components/HtmlContent.tsx | 160 ++ src/components/__tests__/HtmlContent.test.tsx | 72 + .../__snapshots__/HtmlContent.test.tsx.snap | 1845 +++++++++++++++++ src/index.ts | 4 +- 5 files changed, 2081 insertions(+), 2 deletions(-) create mode 100644 src/components/HtmlContent.tsx create mode 100644 src/components/__tests__/HtmlContent.test.tsx create mode 100644 src/components/__tests__/__snapshots__/HtmlContent.test.tsx.snap diff --git a/package.json b/package.json index ddc210f..5b2820f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/HtmlContent.tsx b/src/components/HtmlContent.tsx new file mode 100644 index 0000000..28d2403 --- /dev/null +++ b/src/components/HtmlContent.tsx @@ -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) + | ({ source: RenderHTMLProps['source']; html?: never } & Omit) + +const useHtmlStyles = (): Readonly> => { + 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 +} + +const OlRenderer = ({ InternalRenderer, ...props }: CustomRendererProps) => { + Log.trace('HtmlContent:OlRenderer') + const theme = useTheme() + return ( + + ) +} + +const UlRenderer = ({ TNodeChildrenRenderer, ...props }: CustomRendererProps) => { + Log.trace('HtmlContent:UlRenderer') + const theme = useTheme() + return ( + <> + {props.tnode.children.map((item) => ( + + + + + ))} + + ) +} + +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 ( + + ) +} + +const MemoHtmlContent = React.memo(HtmlContent) +export default MemoHtmlContent +export type { HtmlContentProps } diff --git a/src/components/__tests__/HtmlContent.test.tsx b/src/components/__tests__/HtmlContent.test.tsx new file mode 100644 index 0000000..89a0add --- /dev/null +++ b/src/components/__tests__/HtmlContent.test.tsx @@ -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 = `A picture` + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + + test('Without url', () => { + const html = `A picture` + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + }) + + describe('Override renderer', () => { + test('Override ol', () => { + const html = '
    \n
  1. Ein
  2. \n
  3. Zwei
  4. \n
  5. Drei
  6. \n
' + const renderers = { ol: () => <> } + const { toJSON, queryByText } = render() + expect(queryByText('Ein')).toBeFalsy() + expect(toJSON()).toMatchSnapshot() + }) + + test('Override ul does not affect ol', () => { + const html = '
    \n
  1. Ein
  2. \n
  3. Zwei
  4. \n
  5. Drei
  6. \n
' + const renderers = { ul: () => <> } + const { toJSON, queryByText } = render() + expect(queryByText('Ein')).toBeTruthy() + expect(toJSON()).toMatchSnapshot() + }) + }) + + describe('Custom ordered list renderer', () => { + test('Normal renderer', () => { + const html = '
    \n
  1. Ein
  2. \n
  3. Zwei
  4. \n
  5. Drei
  6. \n
' + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + + test('Ordered list inside an unordered list', () => { + const html = + '
    \n
  • Ein
      \n
    1. Ein
    2. \n
    3. Zwei
    4. \n
    5. Drei
    6. \n
    \n
  • \n
  • Zwei
  • \n
  • Drei
  • \n
' + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + + test('Unordered list inside an ordered list', () => { + const html = + '
    \n
  1. Ein
      \n
    • Ein
    • \n
    • Zwei
    • \n
    • Drei
    • \n
    \n
  2. \n
  3. Zwei
  4. \n
  5. Drei
  6. \n
' + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + }) + + describe('Custom unordered list', () => { + test('Normal render', () => { + const html = '
    \n
  • Ein
  • \n
  • Zwei
  • \n
  • Drei
  • \n
' + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + }) + }) +}) diff --git a/src/components/__tests__/__snapshots__/HtmlContent.test.tsx.snap b/src/components/__tests__/__snapshots__/HtmlContent.test.tsx.snap new file mode 100644 index 0000000..3818e48 --- /dev/null +++ b/src/components/__tests__/__snapshots__/HtmlContent.test.tsx.snap @@ -0,0 +1,1845 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`HtmlContent Rendering Custom image renderer Normal render 1`] = ` + + + + + + + + + + + + A picture + + + + + + + + +`; + +exports[`HtmlContent Rendering Custom image renderer Without url 1`] = ` + + + +`; + +exports[`HtmlContent Rendering Custom ordered list renderer Normal renderer 1`] = ` + + + + + + + 1.  + + + + + + Ein + + + + + + + + 2.  + + + + + + Zwei + + + + + + + + 3.  + + + + + + Drei + + + + + + + +`; + +exports[`HtmlContent Rendering Custom ordered list renderer Ordered list inside an unordered list 1`] = ` + + + + + • + + + Ein + + + + + + 1.  + + + + + + Ein + + + + + + + + 2.  + + + + + + Zwei + + + + + + + + 3.  + + + + + + Drei + + + + + + + + + • + + + Zwei + + + + + • + + + Drei + + + + +`; + +exports[`HtmlContent Rendering Custom ordered list renderer Unordered list inside an ordered list 1`] = ` + + + + + + + 1.  + + + + + + Ein + + + + • + + + Ein + + + + + • + + + Zwei + + + + + • + + + Drei + + + + + + + + + 2.  + + + + + + Zwei + + + + + + + + 3.  + + + + + + Drei + + + + + + + +`; + +exports[`HtmlContent Rendering Custom unordered list Normal render 1`] = ` + + + + + • + + + Ein + + + + + • + + + Zwei + + + + + • + + + Drei + + + + +`; + +exports[`HtmlContent Rendering Override renderer Override ol 1`] = ` + + + +`; + +exports[`HtmlContent Rendering Override renderer Override ul does not affect ol 1`] = ` + + + + + + + 1.  + + + + + + Ein + + + + + + + + 2.  + + + + + + Zwei + + + + + + + + 3.  + + + + + + Drei + + + + + + + +`; diff --git a/src/index.ts b/src/index.ts index be45e87..d717770 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import DocumentLink from './components/DocumentLink' import FilterButton from './components/FilterButton' import FilterOption from './components/FilterOption' import FilterSelectField from './components/FilterSelectField' +import HtmlContent, { HtmlContentProps } from './components/HtmlContent' import IconButton from './components/IconButton' import IconText from './components/IconText' import IconView from './components/IconView' @@ -67,6 +68,7 @@ export { FilterButton, FilterOption, FilterSelectField, + HtmlContent, IconButton, IconText, IconView, @@ -102,6 +104,6 @@ export { useShowBlurView, } -export type { IconName, LargeButtonProps, NotificationPopupStaticProps, TooltipProps } +export type { HtmlContentProps, IconName, LargeButtonProps, NotificationPopupStaticProps, TooltipProps } export * from './components/Icon' export * from './components/BrandIcon' From b8b4ec345c96ff0387badee96cdf03587d39371a Mon Sep 17 00:00:00 2001 From: Sjaak Schilperoort Date: Mon, 14 Sep 2026 17:49:05 +0200 Subject: [PATCH 2/2] Update README --- CLAUDE.md | 14 ++++++++++++++ README.md | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..453fbd8 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 7a16ffe..7b1cffe 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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`