diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
index 13dc7fbaaebb..e9047e2fd588 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
@@ -191,6 +191,11 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim
contentModel: HTMLContentModel.block,
mixedUAStyles: styles.mv3,
}),
+ ol: HTMLElementModel.fromCustomModel({
+ tagName: 'ol',
+ contentModel: HTMLContentModel.block,
+ mixedUAStyles: styles.mv3,
+ }),
'sparkles-icon': HTMLElementModel.fromCustomModel({
tagName: 'sparkles-icon',
contentModel: HTMLContentModel.mixed,
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx
new file mode 100644
index 000000000000..4808ba84a837
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import {View} from 'react-native';
+import type {TNode} from 'react-native-render-html';
+import {TNodeChildrenRenderer} from 'react-native-render-html';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+type NumberedItemRendererProps = {tnode: TNode; index: number};
+
+function NumberedItemRenderer({tnode, index}: NumberedItemRendererProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ {`${index}.`}
+
+
+
+
+ );
+}
+
+export default NumberedItemRenderer;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/OLRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/OLRenderer.tsx
new file mode 100644
index 000000000000..63381b173bad
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/OLRenderer.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import {View} from 'react-native';
+import type {CustomRendererProps, TBlock} from 'react-native-render-html';
+import {TNodeRenderer} from 'react-native-render-html';
+import useThemeStyles from '@hooks/useThemeStyles';
+import NumberedItemRenderer from './NumberedItemRenderer';
+
+function buildLiIndices(children: CustomRendererProps['tnode']['children']): Map {
+ const map = new Map();
+ let counter = 0;
+ for (const [i, child] of children.entries()) {
+ if (child.tagName === 'li') {
+ counter += 1;
+ map.set(i, counter);
+ }
+ }
+ return map;
+}
+
+function OLRenderer({tnode, style}: CustomRendererProps) {
+ const styles = useThemeStyles();
+ const liIndices = buildLiIndices(tnode.children);
+
+ return (
+
+ {tnode.children.map((child, index) => {
+ const key = `${child.tagName ?? 'node'}-${index}`;
+ const liIndex = liIndices.get(index);
+ if (liIndex !== undefined) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export default OLRenderer;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx
index c42c714c053f..e0eeb65d975b 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx
@@ -8,8 +8,7 @@ import BulletItemRenderer from './BulletItemRenderer';
/**
* Bypasses the library's internal ULRenderer (which wraps children in MarkedListItem)
* and renders as a plain block container that draws bullet markers around each
- * direct - child — matching how / render.
- is left
- * unregistered globally so that
- still uses the library's default numeric markers.
+ * direct
- child — matching how / render.
*/
function ULRenderer({tnode, style}: CustomRendererProps) {
const styles = useThemeStyles();
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
index ef1d630ce007..45cd28e8615b 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
@@ -13,6 +13,7 @@ import MentionHereRenderer from './MentionHereRenderer';
import MentionReportRenderer from './MentionReportRenderer';
import MentionUserRenderer from './MentionUserRenderer';
import NextStepEmailRenderer from './NextStepEmailRenderer';
+import OLRenderer from './OLRenderer';
import PreRenderer from './PreRenderer';
import RBRRenderer from './RBRRenderer';
import ShortMentionRenderer from './ShortMentionRenderer';
@@ -31,6 +32,7 @@ const HTMLEngineProviderComponentList: CustomTagRendererRecord = {
a: AnchorRenderer,
code: CodeRenderer,
img: ImageRenderer,
+ ol: OLRenderer,
ul: ULRenderer,
video: VideoRenderer,
diff --git a/src/components/RenderHTML.tsx b/src/components/RenderHTML.tsx
index eccac7bb4044..6d31af35218c 100644
--- a/src/components/RenderHTML.tsx
+++ b/src/components/RenderHTML.tsx
@@ -5,6 +5,7 @@ import useHasTextAncestor from '@hooks/useHasTextAncestor';
import useWindowDimensions from '@hooks/useWindowDimensions';
import Parser from '@libs/Parser';
import BulletItemRenderer from './HTMLEngineProvider/HTMLRenderers/BulletItemRenderer';
+import OLRenderer from './HTMLEngineProvider/HTMLRenderers/OLRenderer';
import SparklesIconRenderer from './HTMLEngineProvider/HTMLRenderers/SparklesIconRenderer';
import ULRenderer from './HTMLEngineProvider/HTMLRenderers/ULRenderer';
@@ -14,8 +15,8 @@ type LinkPressHandler = NonNullable['onPress'];
const RE_BRACKET_ESCAPE = /	[13];/g;
// Matches consecutive duplicate or tags, keeping only the outermost one.
const RE_EMOJI_OPEN_OR_CLOSE = /(]*>)(?:]*>)+|(<\/emoji[^>]*>)(?:<\/emoji[^>]*>)+/g;
-// Strips orphaned
tags inside that would render as extra empty bullets.
-const RE_BR_CLEANUP = /
\s*(<\/ul>)|(<\/li>)\s*
\s*(?=<(?:li|\/ul)>)/gi;
+// Strips one or more orphaned
tags inside and that would render as extra empty items.
+const RE_BR_CLEANUP = /(?:\s*
)+\s*(<\/(?:ul|ol)>)|(<\/li>)(?:\s*
)+\s*(?=<(?:li|\/(?:ul|ol))>)/gi;
type RenderHTMLProps = {
/** HTML string to render */
@@ -46,7 +47,7 @@ function RenderHTML({html: htmlParam, onLinkPress, isSelectable}: RenderHTMLProp
.replaceAll(RE_BRACKET_ESCAPE, (m) => (m.at(7) === '1' ? '[' : ']'))
// Remove double tag if exists and keep the outermost tag (always the original tag).
.replaceAll(RE_EMOJI_OPEN_OR_CLOSE, '$1$2')
- // Strip orphaned
tags inside that would render as extra empty bullets
+ // Strip orphaned
tags inside and that would render as extra empty items
.replaceAll(RE_BR_CLEANUP, '$1$2')
);
}, [htmlParam]);
@@ -63,6 +64,7 @@ function RenderHTML({html: htmlParam, onLinkPress, isSelectable}: RenderHTMLProp
/* eslint-disable @typescript-eslint/naming-convention */
'bullet-item': BulletItemRenderer,
'sparkles-icon': SparklesIconRenderer,
+ ol: OLRenderer,
ul: ULRenderer,
};
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 53c7160d15d5..060a1b9eb91f 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -324,6 +324,14 @@ const staticStyles = (theme: ThemeColors) =>
fontStyle: FontUtils.fontFamily.platform.EXP_NEUE_ITALIC.fontStyle,
},
+ numberedListItemMarker: {
+ fontSize: variables.fontSizeNormal,
+ lineHeight: variables.fontSizeNormalHeight,
+ minWidth: 32,
+ textAlign: 'right',
+ paddingHorizontal: 8,
+ },
+
autoCompleteSuggestionContainer: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/tests/unit/BulletListRendererTest.tsx b/tests/unit/BulletListRendererTest.tsx
index 8e18194d3d53..cfc2c650c350 100644
--- a/tests/unit/BulletListRendererTest.tsx
+++ b/tests/unit/BulletListRendererTest.tsx
@@ -2,6 +2,8 @@ import {render, screen} from '@testing-library/react-native';
import React from 'react';
import type {CustomRendererProps, TBlock} from 'react-native-render-html';
import BulletItemRenderer from '@components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer';
+import NumberedItemRenderer from '@components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer';
+import OLRenderer from '@components/HTMLEngineProvider/HTMLRenderers/OLRenderer';
import ULRenderer from '@components/HTMLEngineProvider/HTMLRenderers/ULRenderer';
import RenderHTML from '@components/RenderHTML';
import CONST from '@src/CONST';
@@ -82,6 +84,50 @@ describe('Bullet list rendering', () => {
});
});
+ describe('OLRenderer', () => {
+ it('wraps each - child with a numbered marker', () => {
+ render(
+ // @ts-expect-error — only the props read by the renderer are needed for this test
+ ,
+ );
+ expect(screen.getByText('1.')).toBeTruthy();
+ expect(screen.getByText('2.')).toBeTruthy();
+ expect(screen.getByText('First')).toBeTruthy();
+ expect(screen.getByText('Second')).toBeTruthy();
+ });
+
+ it('renders non-
- children with the default node renderer', () => {
+ render(
+ // @ts-expect-error — only the props read by the renderer are needed for this test
+ ,
+ );
+ expect(screen.queryByText('1.')).toBeNull();
+ expect(screen.getByText('stray child')).toBeTruthy();
+ });
+ });
+
+ describe('NumberedItemRenderer', () => {
+ it('renders a numbered marker next to the item content', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('1.')).toBeTruthy();
+ expect(screen.getByText('First item')).toBeTruthy();
+ });
+ });
+
describe('RenderHTML strips orphaned
tags inside ', () => {
it('strips
immediately before
', () => {
render();
@@ -103,6 +149,16 @@ describe('Bullet list rendering', () => {
expect(capturedSource.html).toBe('');
});
+ it('strips multiple consecutive
before
', () => {
+ render();
+ expect(capturedSource.html).toBe('');
+ });
+
+ it('strips multiple consecutive
between
and - ', () => {
+ render();
+ expect(capturedSource.html).toBe('');
+ });
+
it('does not strip
outside of lists', () => {
render();
expect(capturedSource.html).toBe('line1
line2
');
@@ -113,4 +169,41 @@ describe('Bullet list rendering', () => {
expect(capturedSource.html).toBe('');
});
});
+
+ describe('RenderHTML strips orphaned
tags inside ', () => {
+ it('strips
immediately before
', () => {
+ render();
+ expect(capturedSource.html).toBe('- One
- Two
');
+ });
+
+ it('strips
(no slash) immediately before
', () => {
+ render();
+ expect(capturedSource.html).toBe('- One
- Two
');
+ });
+
+ it('strips
appearing between and the next - inside
', () => {
+ render();
+ expect(capturedSource.html).toBe('- One
- Two
');
+ });
+
+ it('leaves a valid /- list untouched', () => {
+ render();
+ expect(capturedSource.html).toBe('
- One
- Two
');
+ });
+
+ it('strips multiple consecutive
before
', () => {
+ render();
+ expect(capturedSource.html).toBe('- One
');
+ });
+
+ it('strips multiple consecutive
between
and - inside
', () => {
+ render();
+ expect(capturedSource.html).toBe('- One
- Two
');
+ });
+
+ it('preserves
that lives inside - as an in-item line break', () => {
+ render();
+ expect(capturedSource.html).toBe('
- One
still one - Two
');
+ });
+ });
});