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
142 changes: 142 additions & 0 deletions packages/react-native-enriched-markdown/__tests__/jest-mock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,146 @@ describe('EnrichedMarkdownText mock', () => {
);
expect(byTestId('display').children).toContain('# Title');
});

it('renders links as pressable elements with role "link"', () => {
const { root } = renderMock(
<EnrichedMarkdownText
testID="display"
markdown="Click [here](https://example.com) for info"
onLinkPress={jest.fn()}
/>
);
const link = root.container.queryAll(
(i) => i.props.accessibilityRole === 'link'
)[0];
expect(link).toBeDefined();
expect(link!.children).toContain('here');
});

it('calls onLinkPress with the url when a link is pressed', () => {
const onLinkPress = jest.fn();
const { root } = renderMock(
<EnrichedMarkdownText
testID="display"
markdown="See [docs](https://docs.example.com/path)"
onLinkPress={onLinkPress}
/>
);
const link = root.container.queryAll(
(i) => i.props.accessibilityRole === 'link'
)[0]!;

act(() => {
link.props.onPress();
});

expect(onLinkPress).toHaveBeenCalledWith({
url: 'https://docs.example.com/path',
});
});

it('calls onLinkLongPress with the url when a link is long-pressed', () => {
const onLinkLongPress = jest.fn();
const { root } = renderMock(
<EnrichedMarkdownText
testID="display"
markdown="[link](https://example.com)"
onLinkLongPress={onLinkLongPress}
/>
);
const link = root.container.queryAll(
(i) => i.props.accessibilityRole === 'link'
)[0]!;

act(() => {
link.props.onLongPress();
});

expect(onLinkLongPress).toHaveBeenCalledWith({
url: 'https://example.com',
});
});

it('strips inline formatting from link text', () => {
const { root } = renderMock(
<EnrichedMarkdownText
testID="display"
markdown="[**bold link**](https://example.com)"
onLinkPress={jest.fn()}
/>
);
const link = root.container.queryAll(
(i) => i.props.accessibilityRole === 'link'
)[0]!;
expect(link.children).toContain('bold link');
});

it('renders plain text when no onLinkPress is provided (no transform)', () => {
const { byTestId } = renderMock(
<EnrichedMarkdownText
testID="display"
markdown="See [docs](https://example.com)"
/>
);
const links = byTestId('display').queryAll(
(i) => i.props.accessibilityRole === 'link'
);
expect(links).toHaveLength(0);
expect(byTestId('display').children).toContain(
'See [docs](https://example.com)'
);
});

it('renders task list items as pressable checkboxes', () => {
const onTaskListItemPress = jest.fn();
const { root } = renderMock(
<EnrichedMarkdownText
testID="display"
flavor="github"
markdown={'- [ ] Buy milk\n- [x] Write code'}
onTaskListItemPress={onTaskListItemPress}
/>
Comment thread
Copilot marked this conversation as resolved.
);
const checkboxes = root.container.queryAll(
(i) => i.props.accessibilityRole === 'checkbox'
);
expect(checkboxes).toHaveLength(2);
expect(checkboxes[0]!.props.accessibilityState).toEqual({ checked: false });
expect(checkboxes[1]!.props.accessibilityState).toEqual({ checked: true });
});

it('calls onTaskListItemPress with toggled state on checkbox press', () => {
const onTaskListItemPress = jest.fn();
const { root } = renderMock(
<EnrichedMarkdownText
testID="display"
flavor="github"
markdown={'- [ ] First task\n- [x] Second task'}
onTaskListItemPress={onTaskListItemPress}
/>
);
const checkboxes = root.container.queryAll(
(i) => i.props.accessibilityRole === 'checkbox'
);

act(() => {
checkboxes[0]!.props.onPress();
});

expect(onTaskListItemPress).toHaveBeenCalledWith({
index: 0,
checked: true,
text: 'First task',
});

act(() => {
checkboxes[1]!.props.onPress();
});

expect(onTaskListItemPress).toHaveBeenCalledWith({
index: 1,
checked: false,
text: 'Second task',
});
});
});
142 changes: 137 additions & 5 deletions packages/react-native-enriched-markdown/src/jest/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@
* silently drift from the real component.
*/
import { useImperativeHandle, useRef, useState } from 'react';
import type { ComponentRef } from 'react';
import type { ComponentRef, ReactNode } from 'react';
import { Text, TextInput } from 'react-native';
import type {
CaretRect,
EnrichedMarkdownTextInputInstance,
EnrichedMarkdownTextInputProps,
} from '../EnrichedMarkdownTextInput';
import type { EnrichedMarkdownTextProps } from '../native/EnrichedMarkdownText';
import type {
LinkPressEvent,
LinkLongPressEvent,
TaskListItemPressEvent,
} from '../types/events';

export {
DEFAULT_ACCESSIBILITY_LABELS,
Expand Down Expand Up @@ -155,6 +160,129 @@ export const EnrichedMarkdownTextInput = ({
);
};

// Composable transforms: split raw-string segments into React children.
// To add a feature, write a transform and append it to `buildChildren`.
type Segment = string | ReactNode;
type TransformFn = (text: string) => Segment[];

type BuildChildrenOptions = Pick<
EnrichedMarkdownTextProps,
'onLinkPress' | 'onLinkLongPress' | 'onTaskListItemPress'
>;

const INLINE_FMT_RE = /\*{1,2}|_{1,2}/g;
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
const TASK_ITEM_RE = /^- \[([ xX])\] (.+)$/gm;

function stripInlineFormatting(text: string): string {
return text.replace(INLINE_FMT_RE, '');
}

function splitByPattern(
segment: string,
regex: RegExp,
renderMatch: (match: RegExpExecArray) => Segment
): Segment[] {
const parts: Segment[] = [];
let lastIndex = 0;
regex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(segment)) !== null) {
if (match.index > lastIndex) {
parts.push(segment.slice(lastIndex, match.index));
}
parts.push(renderMatch(match));
lastIndex = match.index + match[0].length;
}
if (lastIndex < segment.length) {
parts.push(segment.slice(lastIndex));
}
return parts.length > 0 ? parts : [segment];
}

function applyTransform(
children: Segment[],
transformFn: TransformFn
): Segment[] {
return children.flatMap((child) =>
typeof child === 'string' ? transformFn(child) : child
);
}

function createLinkTransform(
onLinkPress?: (event: LinkPressEvent) => void,
onLinkLongPress?: (event: LinkLongPressEvent) => void
): TransformFn {
let keyCounter = 0;
return (segment) =>
splitByPattern(segment, LINK_RE, (match) => {
const linkText = stripInlineFormatting(match[1]!);
const url = match[2]!;
return (
<Text
key={`link-${keyCounter++}`}
accessibilityRole="link"
onPress={() => onLinkPress?.({ url })}
onLongPress={
onLinkLongPress ? () => onLinkLongPress({ url }) : undefined
}
>
{linkText}
</Text>
);
});
}

function createTaskListTransform(
onTaskListItemPress?: (event: TaskListItemPressEvent) => void
): TransformFn {
let itemIndex = 0;
return (segment) =>
splitByPattern(segment, TASK_ITEM_RE, (match) => {
const checked = match[1] !== ' ';
const text = match[2]!;
const currentIndex = itemIndex++;
return (
<Text
key={`task-${currentIndex}`}
accessibilityRole="checkbox"
accessibilityState={{ checked }}
onPress={() =>
onTaskListItemPress?.({
index: currentIndex,
checked: !checked,
text,
})
}
>
{`${checked ? '☑' : '☐'} ${text}`}
</Text>
);
Comment thread
hryhoriiK97 marked this conversation as resolved.
});
}

function buildChildren(
markdown: string | undefined,
opts: BuildChildrenOptions
): Segment[] {
let children: Segment[] = [String(markdown ?? '')];

const transforms: TransformFn[] = [
opts.onTaskListItemPress
? createTaskListTransform(opts.onTaskListItemPress)
: undefined,
opts.onLinkPress || opts.onLinkLongPress
? createLinkTransform(opts.onLinkPress, opts.onLinkLongPress)
: undefined,
].filter((t): t is TransformFn => t != null);
Comment thread
Copilot marked this conversation as resolved.

for (const transform of transforms) {
children = applyTransform(children, transform);
}

return children;
}

export const EnrichedMarkdownText = ({
markdown,
containerStyle,
Expand All @@ -166,9 +294,9 @@ export const EnrichedMarkdownText = ({
accessibilityState,
nativeID,
markdownStyle: _markdownStyle,
onLinkPress: _onLinkPress,
onLinkLongPress: _onLinkLongPress,
onTaskListItemPress: _onTaskListItemPress,
onLinkPress,
onLinkLongPress,
onTaskListItemPress,
enableLinkPreview: _enableLinkPreview,
selectable: _selectable,
md4cFlags: _md4cFlags,
Expand Down Expand Up @@ -200,7 +328,11 @@ export const EnrichedMarkdownText = ({
accessibilityState={accessibilityState}
nativeID={nativeID}
>
{markdown}
{buildChildren(markdown, {
onLinkPress,
onLinkLongPress,
onTaskListItemPress,
})}
</Text>
);
};
Loading