diff --git a/packages/twenty-front-component-renderer/scripts/front-component-stories/build-source-examples.ts b/packages/twenty-front-component-renderer/scripts/front-component-stories/build-source-examples.ts index cd1e3a7db0..551f07a6a4 100644 --- a/packages/twenty-front-component-renderer/scripts/front-component-stories/build-source-examples.ts +++ b/packages/twenty-front-component-renderer/scripts/front-component-stories/build-source-examples.ts @@ -86,6 +86,7 @@ const STORY_COMPONENTS = [ 'form-events.front-component', 'keyboard-events.front-component', 'host-api-calls.front-component', + 'caret-preservation.front-component', ]; const resolveEntryPoints = (): Record => { diff --git a/packages/twenty-front-component-renderer/src/__stories__/EventForwarding.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/EventForwarding.stories.tsx index 4a508c2e96..d3e28f02af 100644 --- a/packages/twenty-front-component-renderer/src/__stories__/EventForwarding.stories.tsx +++ b/packages/twenty-front-component-renderer/src/__stories__/EventForwarding.stories.tsx @@ -317,6 +317,107 @@ export const HostApiProgress: Story = createHostApiStory( }, ); +const TYPING_TIMEOUT = 10000; + +const expectCaretAt = async ( + element: HTMLInputElement | HTMLTextAreaElement, + position: number, +): Promise => { + await waitFor( + () => { + expect(element.selectionStart).toBe(position); + expect(element.selectionEnd).toBe(position); + }, + { timeout: TYPING_TIMEOUT }, + ); +}; + +export const InputCaretPreservedMidString: Story = createComponentStory( + 'caret-preservation', + { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId( + 'caret-preservation-component', + {}, + { timeout: MOUNT_TIMEOUT }, + ); + + const input = (await canvas.findByTestId( + 'caret-text-input', + )) as HTMLInputElement; + + await waitFor( + () => { + expect(input.value).toBe('Hello world'); + }, + { timeout: INTERACTION_TIMEOUT }, + ); + + input.focus(); + input.setSelectionRange(4, 4); + + await userEvent.keyboard('X'); + + await waitFor( + () => { + expect(input.value).toBe('HellXo world'); + expect(canvas.getByTestId('caret-text-value').textContent).toBe( + 'HellXo world', + ); + }, + { timeout: TYPING_TIMEOUT }, + ); + + await expectCaretAt(input, 5); + }, + }, +); + +export const TextareaCaretPreservedMidString: Story = createComponentStory( + 'caret-preservation', + { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId( + 'caret-preservation-component', + {}, + { timeout: MOUNT_TIMEOUT }, + ); + + const textarea = (await canvas.findByTestId( + 'caret-textarea-input', + )) as HTMLTextAreaElement; + + await waitFor( + () => { + expect(textarea.value).toBe('Hello world'); + }, + { timeout: INTERACTION_TIMEOUT }, + ); + + textarea.focus(); + textarea.setSelectionRange(4, 4); + + await userEvent.keyboard('X'); + + await waitFor( + () => { + expect(textarea.value).toBe('HellXo world'); + expect(canvas.getByTestId('caret-textarea-value').textContent).toBe( + 'HellXo world', + ); + }, + { timeout: TYPING_TIMEOUT }, + ); + + await expectCaretAt(textarea, 5); + }, + }, +); + export const HostApiClosePanel: Story = createHostApiStory( async ({ canvasElement, args }) => { const canvas = within(canvasElement); diff --git a/packages/twenty-front-component-renderer/src/__stories__/example-sources/caret-preservation.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/example-sources/caret-preservation.front-component.tsx new file mode 100644 index 0000000000..9a9ae6b910 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/example-sources/caret-preservation.front-component.tsx @@ -0,0 +1,98 @@ +import { defineFrontComponent } from 'twenty-sdk/define'; +import { type ChangeEvent, useState } from 'react'; + +const CARD_STYLE = { + padding: 24, + backgroundColor: '#eff6ff', + border: '2px solid #3b82f6', + borderRadius: 12, + fontFamily: 'system-ui, sans-serif', + display: 'flex', + flexDirection: 'column' as const, + gap: 16, + maxWidth: 400, +}; + +const HEADING_STYLE = { + color: '#1e3a8a', + fontWeight: 700, + fontSize: 18, + margin: 0, +}; + +const LABEL_STYLE = { + fontSize: 13, + fontWeight: 600, + color: '#374151', +}; + +const HINT_STYLE = { + fontSize: 13, + color: '#6b7280', + fontFamily: 'monospace', +}; + +const INPUT_STYLE = { + padding: '8px 12px', + border: '1px solid #d1d5db', + borderRadius: 6, + fontSize: 14, + fontFamily: 'monospace', +}; + +const INITIAL_VALUE = 'Hello world'; + +const CaretPreservationComponent = () => { + const [text, setText] = useState(INITIAL_VALUE); + const [textareaText, setTextareaText] = useState(INITIAL_VALUE); + + return ( +
+

Caret Preservation

+ +
+ + ) => { + const detail = (event as unknown as { detail: { value?: string } }) + .detail; + setText(detail?.value ?? ''); + }} + style={INPUT_STYLE} + /> + + {text} + +
+ +
+ +