Skip to content

Commit 57decb3

Browse files
pkaramonexploIF
andauthored
feat(web): submit props (#600)
# Summary + implemented handling of `submitBehavior`, `onSubmitEditing` and partially `returnKeyType`(only a portion of options work, https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/enterkeyhint) + `returnKeyLabel` is not implemented as browser APIs do not allow such modifications + wrote tests for new props, and new callback ## Test Plan + Launch example-web app. + Play around with submit props in `apps/example-web/App.tsx`. + Run `yarn test:e2e:web` to see if new e2e tests pass. + To test out `returnKeyLabel` you can run the example-web inside simulator's/emulator's browsers ## Screenshots / Videos `returnKeyType` in action: <img width="956" height="929" alt="Screenshot 2026-05-11 at 12 27 23" src="https://github.com/user-attachments/assets/5554881a-b222-4ae9-9eaf-bc9803adf614" /> ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ❌ | | Web | ✅ | ## Checklist - [x] E2E tests are passing - [x] Required E2E tests have been added (if applicable) --------- Co-authored-by: Igor Furgała <74370735+exploIF@users.noreply.github.com>
1 parent 17447af commit 57decb3

8 files changed

Lines changed: 296 additions & 5 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
const EDITOR_SELECTOR =
4+
'[data-testid="test-submit-root"] .eti-editor [contenteditable="true"]';
5+
const SUBMIT_PAGE = '/test-submit-props';
6+
const SUBMIT_LOG_SELECTOR = '[data-testid="submit-log"]';
7+
const TYPE_DELAY_MS = 50;
8+
9+
interface SubmitLogSnapshot {
10+
submitCount: number;
11+
text: string;
12+
}
13+
14+
async function readSubmitLog(page: Page): Promise<SubmitLogSnapshot> {
15+
const raw = await page.locator(SUBMIT_LOG_SELECTOR).innerText();
16+
return JSON.parse(raw.trim()) as SubmitLogSnapshot;
17+
}
18+
19+
async function expectSubmitLog(
20+
page: Page,
21+
expected: SubmitLogSnapshot
22+
): Promise<void> {
23+
await expect.poll(async () => readSubmitLog(page)).toStrictEqual(expected);
24+
}
25+
26+
test.describe('submit and keyboard props', () => {
27+
test('newline mode inserts a paragraph break on Enter', async ({ page }) => {
28+
await page.goto(`${SUBMIT_PAGE}?mode=newline`);
29+
await page.waitForSelector(EDITOR_SELECTOR);
30+
31+
const editor = page.locator(EDITOR_SELECTOR);
32+
await editor.click();
33+
await expect(editor).toBeFocused();
34+
35+
await editor.pressSequentially(`Hello`, { delay: TYPE_DELAY_MS });
36+
await editor.press('Enter');
37+
await editor.pressSequentially(`World`, { delay: TYPE_DELAY_MS });
38+
39+
await expect(page.locator('.eti-editor p')).toHaveCount(2);
40+
await expect(page.locator(SUBMIT_LOG_SELECTOR)).toContainText(
41+
'"submitCount":0'
42+
);
43+
});
44+
45+
test('submit mode fires onSubmitEditing without adding a paragraph', async ({
46+
page,
47+
}) => {
48+
await page.goto(`${SUBMIT_PAGE}?mode=submit`);
49+
await page.waitForSelector(EDITOR_SELECTOR);
50+
51+
const editor = page.locator(EDITOR_SELECTOR);
52+
await editor.click();
53+
await expect(editor).toBeFocused();
54+
55+
await editor.pressSequentially(`Hi`, { delay: TYPE_DELAY_MS });
56+
await editor.press('Enter');
57+
58+
await expectSubmitLog(page, {
59+
submitCount: 1,
60+
text: 'Hi',
61+
});
62+
63+
await expect(page.locator('.eti-editor p')).toHaveCount(1);
64+
});
65+
66+
test('blurAndSubmit fires onSubmitEditing without adding a paragraph then blurs', async ({
67+
page,
68+
}) => {
69+
await page.goto(`${SUBMIT_PAGE}?mode=blurAndSubmit`);
70+
await page.waitForSelector(EDITOR_SELECTOR);
71+
72+
const editor = page.locator(EDITOR_SELECTOR);
73+
await editor.click();
74+
await expect(editor).toBeFocused();
75+
76+
await editor.pressSequentially('x', { delay: TYPE_DELAY_MS });
77+
await editor.press('Enter');
78+
79+
await expectSubmitLog(page, {
80+
submitCount: 1,
81+
text: 'x',
82+
});
83+
84+
await expect(page.locator('.eti-editor p')).toHaveCount(1);
85+
await expect(editor).not.toBeFocused();
86+
});
87+
88+
test('returnKeyType sets enterkeyhint on the editable root', async ({
89+
page,
90+
}) => {
91+
await page.goto(`${SUBMIT_PAGE}?enterKeyTest=1&returnKeyType=search`);
92+
await page.waitForSelector(EDITOR_SELECTOR);
93+
94+
const editor = page.locator(EDITOR_SELECTOR);
95+
await expect(editor).toHaveAttribute('enterkeyhint', 'search');
96+
});
97+
});

apps/example-web/src/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type BlurEvent,
1111
type EnrichedInputStyle,
1212
type OnLinkDetected,
13+
type OnSubmitEditing,
1314
type OnChangeMentionEvent,
1415
type OnMentionDetected,
1516
} from 'react-native-enriched';
@@ -205,6 +206,10 @@ function App() {
205206
setEditorState(e.nativeEvent);
206207
};
207208

209+
const handleSubmitEditing = (e: NativeSyntheticEvent<OnSubmitEditing>) => {
210+
console.log('[EnrichedTextInput] onSubmitEditing event', e.nativeEvent);
211+
};
212+
208213
const handleOnLinkDetected = (e: OnLinkDetected) => {
209214
console.log('[EnrichedTextInput] onLinkDetected event', e);
210215
setCurrentLink(e);
@@ -236,6 +241,7 @@ function App() {
236241
onChangeSelection={handleChangeSelection}
237242
onChangeHtml={handleOnChangeHtml}
238243
onChangeState={handleChangeState}
244+
onSubmitEditing={handleSubmitEditing}
239245
onLinkDetected={handleOnLinkDetected}
240246
onStartMention={handleStartMention}
241247
onChangeMention={handleChangeMention}

apps/example-web/src/RouteSelector.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { TestMentions } from './testScreens/TestMentions';
33
import { TestLinks } from './testScreens/TestLinks';
44
import { TestSetSelection } from './testScreens/TestSetSelection';
55
import { VisualRegression } from './testScreens/VisualRegression';
6+
import { TestSubmitProps } from './testScreens/TestSubmitProps';
67
import { useEffect, useState } from 'react';
78

89
export default function RouteSelector() {
@@ -31,6 +32,10 @@ export default function RouteSelector() {
3132
return <VisualRegression />;
3233
}
3334

35+
if (path === '/test-submit-props') {
36+
return <TestSubmitProps />;
37+
}
38+
3439
if (path === '/test-mentions') {
3540
return <TestMentions />;
3641
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { useRef, useState } from 'react';
2+
import {
3+
EnrichedTextInput,
4+
type EnrichedTextInputInstance,
5+
type EnrichedTextInputProps,
6+
type OnSubmitEditing,
7+
} from 'react-native-enriched';
8+
import type { NativeSyntheticEvent, ReturnKeyTypeOptions } from 'react-native';
9+
10+
const editorWrapStyle = {
11+
backgroundColor: '#ddd',
12+
padding: '16px',
13+
borderRadius: '8px',
14+
} as const;
15+
16+
export function TestSubmitProps() {
17+
const ref = useRef<EnrichedTextInputInstance>(null);
18+
const params = new URLSearchParams(window.location.search);
19+
20+
const modeParam = params.get('mode');
21+
const mode: 'newline' | 'submit' | 'blurAndSubmit' =
22+
modeParam === 'submit' || modeParam === 'blurAndSubmit'
23+
? modeParam
24+
: 'newline';
25+
26+
const enterKeyTest = params.get('enterKeyTest') === '1';
27+
const returnKeyQuery = params.get('returnKeyType') ?? 'search';
28+
29+
const submitBehavior: EnrichedTextInputProps['submitBehavior'] = enterKeyTest
30+
? 'newline'
31+
: mode === 'newline'
32+
? 'newline'
33+
: mode;
34+
35+
const returnKeyType: ReturnKeyTypeOptions | undefined = enterKeyTest
36+
? (returnKeyQuery as ReturnKeyTypeOptions)
37+
: undefined;
38+
39+
const [submitLog, setSubmitLog] = useState({
40+
submitCount: 0,
41+
text: '',
42+
});
43+
44+
const handleSubmitEditing = (e: NativeSyntheticEvent<OnSubmitEditing>) => {
45+
setSubmitLog((prev) => ({
46+
submitCount: prev.submitCount + 1,
47+
text: e.nativeEvent.text,
48+
}));
49+
};
50+
51+
return (
52+
<div data-testid="test-submit-root">
53+
<p data-testid="test-submit-mode-label">{mode}</p>
54+
<div
55+
className="editor-wrapper"
56+
style={editorWrapStyle}
57+
onClick={() => ref.current?.focus()}
58+
>
59+
<EnrichedTextInput
60+
ref={ref}
61+
placeholder="Submit / keyboard test"
62+
autoFocus
63+
editable
64+
scrollEnabled
65+
submitBehavior={submitBehavior}
66+
returnKeyType={returnKeyType}
67+
onSubmitEditing={handleSubmitEditing}
68+
/>
69+
</div>
70+
<pre data-testid="submit-log">
71+
{JSON.stringify({
72+
submitCount: submitLog.submitCount,
73+
text: submitLog.text,
74+
})}
75+
</pre>
76+
</div>
77+
);
78+
}

docs/WEB.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ Web support is still experimental. APIs and behavior can change in future releas
1313
- Mentions
1414
- `getHTML`, `setValue`, selection mapping
1515
- Core callbacks: `onChange`, `onChangeState`, `onFocus`, `onBlur`, `onSelectionChange`
16+
- Submit props: `submitBehavior` and `onSubmitEditing`. `returnKeyType` is only a hint, it maps to [enterkeyhint](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint) (`done`, `go`, `next`, `previous`, `search`, `send`, `default`/`enter`). Not all values of `ReturnKeyTypeOptions` are supported, the behavior of this prop is heavily dependent on the browser's capabilities.
1617
- Input theming via `placeholderTextColor`, `cursorColor` and `selectionColor` props
1718

1819
## Unsupported
1920

21+
- **`returnKeyLabel`**: ignored on web, it's not possible to set it inside a browser.
2022
- **Pasting images**: `onPasteImages` is never called.
2123
- **Automatic link detection**: `linkRegex` is ignored. Links only work when set explicitly via the `setLink` ref method.
22-
- **Submit and keyboard props**: `onSubmitEditing`, `returnKeyType`, `returnKeyLabel`, and `submitBehavior` have no effect.
2324
- **Context menu**: `contextMenuItems` is ignored.
2425
- **HTML normalizer flag**: `useHtmlNormalizer` is ignored; paste behavior follows the browser pipeline.
2526
- **RN layout ref methods**: `measure`, `measureInWindow`, `measureLayout`, and `setNativeProps` are no-ops.

src/web/EnrichedTextInput.tsx

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type CSSProperties,
77
} from 'react';
88
import './EnrichedTextInput.css';
9+
import type { Node } from '@tiptap/pm/model';
910
import type {
1011
EnrichedTextInputInstance,
1112
EnrichedTextInputProps,
@@ -70,6 +71,8 @@ import {
7071
} from './pmPlugins/mentionPlugin';
7172

7273
import { StripMarksOnImagePlugin } from './pmPlugins/stripMarksOnImagePlugin';
74+
import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint';
75+
7376
function runFocused(
7477
editor: Editor,
7578
apply: (chain: ChainedCommands) => ChainedCommands
@@ -98,6 +101,9 @@ export const EnrichedTextInput = ({
98101
onChangeHtml,
99102
onChangeState,
100103
onLinkDetected,
104+
onSubmitEditing,
105+
returnKeyType,
106+
submitBehavior,
101107
onMentionDetected,
102108
onStartMention,
103109
onChangeMention,
@@ -151,6 +157,41 @@ export const EnrichedTextInput = ({
151157
[]
152158
);
153159

160+
const submitBehaviorRef = useRef(submitBehavior);
161+
const onSubmitEditingRef = useRef(onSubmitEditing);
162+
const onKeyPressRef = useRef(onKeyPress);
163+
const editorInstanceRef = useRef<Editor | null>(null);
164+
165+
useEffect(() => {
166+
submitBehaviorRef.current = submitBehavior;
167+
}, [submitBehavior]);
168+
useEffect(() => {
169+
onSubmitEditingRef.current = onSubmitEditing;
170+
}, [onSubmitEditing]);
171+
useEffect(() => {
172+
onKeyPressRef.current = onKeyPress;
173+
}, [onKeyPress]);
174+
175+
const handleKeyDown = (doc: Node, event: KeyboardEvent): boolean => {
176+
onKeyPressRef.current?.(adaptWebToNativeEvent(event, { key: event.key }));
177+
if (event.key !== 'Enter') {
178+
return false;
179+
}
180+
181+
const sb = submitBehaviorRef.current;
182+
if (sb === 'submit' || sb === 'blurAndSubmit') {
183+
event.preventDefault();
184+
const text = nativeLeafText(doc, 0, doc.content.size);
185+
onSubmitEditingRef.current?.(adaptWebToNativeEvent(event, { text }));
186+
if (sb === 'blurAndSubmit') {
187+
editorInstanceRef.current?.commands.blur();
188+
}
189+
return true;
190+
}
191+
192+
return false;
193+
};
194+
154195
const extensions = useMemo(
155196
() => [
156197
Document,
@@ -211,18 +252,34 @@ export const EnrichedTextInput = ({
211252
onChangeSelection?.(adaptWebToNativeEvent(null, { start, end, text }));
212253
},
213254
editorProps: {
214-
handleKeyDown: (_, event) => {
215-
onKeyPress?.(adaptWebToNativeEvent(event, { key: event.key }));
216-
return false;
217-
},
255+
handleKeyDown: (view, event) => handleKeyDown(view.state.doc, event),
218256
attributes: {
219257
autoCapitalize,
258+
enterkeyhint: returnKeyTypeToEnterKeyHint(returnKeyType),
220259
},
221260
},
222261
},
223262
[tiptapContent, extensions]
224263
);
225264

265+
useEffect(() => {
266+
editorInstanceRef.current = editor ?? null;
267+
}, [editor]);
268+
269+
useEffect(() => {
270+
if (!editor) return;
271+
let dom: HTMLElement;
272+
try {
273+
dom = editor.view.dom;
274+
} catch {
275+
return;
276+
}
277+
dom.setAttribute(
278+
'enterkeyhint',
279+
returnKeyTypeToEnterKeyHint(returnKeyType)
280+
);
281+
}, [editor, returnKeyType]);
282+
226283
useEffect(() => {
227284
editor?.commands.normalizeBoldInStyledHeadings();
228285
}, [editor, resolvedHtmlStyle]);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { returnKeyTypeToEnterKeyHint } from '../returnKeyTypeToEnterKeyHint';
2+
3+
describe('returnKeyTypeToEnterKeyHint', () => {
4+
test.each([
5+
[undefined, 'enter'],
6+
['default', 'enter'],
7+
['done', 'done'],
8+
['go', 'go'],
9+
['next', 'next'],
10+
['previous', 'previous'],
11+
['search', 'search'],
12+
['send', 'send'],
13+
['google', 'enter'],
14+
['yahoo', 'enter'],
15+
] as const)('%j → %s', (input, expected) => {
16+
expect(returnKeyTypeToEnterKeyHint(input)).toBe(expected);
17+
});
18+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { ReturnKeyTypeOptions } from 'react-native';
2+
3+
// Keywords for the HTML global [`enterkeyhint`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/enterkeyhint) attribute
4+
export type EnterKeyHint =
5+
| 'enter'
6+
| 'done'
7+
| 'go'
8+
| 'next'
9+
| 'previous'
10+
| 'search'
11+
| 'send';
12+
13+
// Maps React Native `returnKeyType` to the HTML global `enterkeyhint` attribute
14+
// if possible, otherwise falls back to enter.
15+
export function returnKeyTypeToEnterKeyHint(
16+
returnKeyType: ReturnKeyTypeOptions | undefined
17+
): EnterKeyHint {
18+
switch (returnKeyType) {
19+
case 'done':
20+
case 'go':
21+
case 'next':
22+
case 'previous':
23+
case 'search':
24+
case 'send':
25+
return returnKeyType;
26+
default:
27+
return 'enter';
28+
}
29+
}

0 commit comments

Comments
 (0)