diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.stories.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.stories.tsx index d005412f2..dbf342606 100644 --- a/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.stories.tsx +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.stories.tsx @@ -9,7 +9,9 @@ const meta: Meta = { docs: { description: { component: ` -**EditableText** is the click-to-edit text used by the node identity row. It reads as plain +**EditableText** is the click-to-edit text used by the node identity row: apollo-wind's +EditableText, with React Flow's \`nodrag\` and \`nowheel\` classes so dragging or scrolling over +it leaves the canvas alone. It reads as plain text, turns into a field on click, submits on Enter or blur, and reverts on Escape. It is a controlled field: \`onChange\` fires per keystroke and \`value\` has to follow it, which is what lets the owner validate what is being typed and answer through \`error\`. Omit either callback and diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.test.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.test.tsx index 3a61bf130..15bb855e6 100644 --- a/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.test.tsx +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.test.tsx @@ -1,132 +1,36 @@ -import { type HTMLAttributes, type ReactNode, useState } from 'react'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { render, screen, userEvent } from '../../utils/testing'; -import type { EditableTextProps } from './EditableText'; -import { EditableText } from './EditableText'; +import { EditableText, type EditableTextProps } from './EditableText'; -// Stand in for the real overflow detection, which needs layout jsdom does not do. -vi.mock('../CanvasTooltip', async () => { - const React = await import('react'); +// The editing behaviour is apollo-wind's and tested there; this covers what the canvas adds. - return { - CanvasTooltip: ({ content, children }: { content: ReactNode; children: ReactNode }) => - React.isValidElement(children) - ? React.cloneElement(children, { - 'data-tooltip-trigger': typeof content === 'string' ? content : 'true', - } as HTMLAttributes) - : children, - }; -}); - -/** Owns the text, as a real consumer must: the field only shows what comes back through `value`. */ function Controlled({ initial, - validate, - onChangeSpy, ...props -}: { - initial: string; - validate?: (text: string) => string | undefined; - onChangeSpy?: (next: string) => void; -} & Omit) { +}: { initial: string } & Omit) { const [text, setText] = useState(initial); - - return ( - { - onChangeSpy?.(next); - setText(next); - }} - error={props.error ?? validate?.(text)} - /> - ); + return ; } describe('EditableText', () => { - it('renders static text with no interactive affordance when the callbacks are omitted', () => { - render(); - expect(screen.getByText('End')).toBeInTheDocument(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); - - it('stays static without onSubmit, since it would have nowhere to commit', () => { - render(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); - - it('does not enter edit mode when disabled', async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByRole('button', { name: /^Node name/ }); - await user.click(trigger); - - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - // The trigger stays a focusable button (aria-disabled, not disabled), so a - // truncated value keeps its overflow tooltip. - expect(trigger).toHaveAttribute('data-tooltip-trigger', 'End'); - expect(trigger).not.toBeDisabled(); - }); - - it('leaves edit mode without submitting when disabled mid-edit', async () => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - const { rerender } = render( - - ); - - await user.click(screen.getByRole('button', { name: /^Node name/ })); - rerender( - - ); - - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - expect(onSubmit).not.toHaveBeenCalled(); - // The text belongs to the owner, so it survives the editor closing. - expect(screen.getByRole('button', { name: /^Node name/ })).toHaveTextContent('Ending'); - }); - - it('shows the placeholder in place of an empty value', () => { - render(); - expect(screen.getByRole('button', { name: 'Control' })).toBeInTheDocument(); - }); - - it('enters edit mode on a single click and selects the current value', async () => { + it('keeps React Flow from panning or zooming over the text, in read and edit mode', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole('button', { name: /^Node name/ })); + const trigger = screen.getByRole('button', { name: 'Node name: End' }); + expect(trigger).toHaveClass('nodrag', 'nowheel'); - const input = screen.getByRole('textbox', { name: /^Node name/ }); - expect(input).toHaveFocus(); - expect(input).toHaveValue('End'); - expect((input as HTMLInputElement).selectionStart).toBe(0); - expect((input as HTMLInputElement).selectionEnd).toBe(3); + await user.click(trigger); + expect(screen.getByRole('textbox', { name: 'Node name' })).toHaveClass('nodrag', 'nowheel'); }); - it('reports every keystroke through onChange', async () => { - const user = userEvent.setup(); - const onChangeSpy = vi.fn(); - render( - - ); - - await user.click(screen.getByRole('button', { name: /^Node name/ })); - await user.keyboard('End'); - - expect(onChangeSpy.mock.calls.map(([next]) => next)).toEqual(['E', 'En', 'End']); - expect(screen.getByRole('textbox', { name: 'Node name' })).toHaveValue('End'); + it('keeps a consumer className alongside', () => { + render(); + expect(screen.getByText('End')).toHaveClass('nodrag', 'nowheel', 'text-brand'); }); - it('submits the trimmed value on Enter and closes', async () => { + it('edits through apollo-wind: Enter submits the trimmed text', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(); @@ -135,188 +39,5 @@ describe('EditableText', () => { await user.keyboard(' Wrap up {Enter}'); expect(onSubmit).toHaveBeenCalledExactlyOnceWith('Wrap up'); - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - // The trim reaches the owner too, so read mode shows what was submitted. - expect(screen.getByRole('button', { name: /^Node name/ })).toHaveTextContent('Wrap up'); - }); - - it('submits on blur', async () => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - render( - <> - - - - ); - - await user.click(screen.getByRole('button', { name: /^Node name/ })); - await user.keyboard('Wrap up'); - await user.click(screen.getByRole('button', { name: 'elsewhere' })); - - expect(onSubmit).toHaveBeenCalledExactlyOnceWith('Wrap up'); - }); - - it('reverts to the text it opened with on Escape, without submitting', async () => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - render(); - - await user.click(screen.getByRole('button', { name: /^Node name/ })); - await user.keyboard('Wrap up{Escape}'); - - expect(onSubmit).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: /^Node name/ })).toHaveTextContent('End'); - }); - - it('does not submit when the editor closes on the text it opened with', async () => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - render( - <> - - - - ); - - await user.click(screen.getByRole('button', { name: /^Node name/ })); - await user.click(screen.getByRole('button', { name: 'elsewhere' })); - - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('submits an empty string when the value is cleared, so the caller can delete it', async () => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - render(); - - await user.click(screen.getByRole('button', { name: /^Node name/ })); - await user.keyboard('{Backspace}{Enter}'); - - expect(onSubmit).toHaveBeenCalledExactlyOnceWith(''); - }); - - it('renders owner feedback about the text being typed', async () => { - const user = userEvent.setup(); - render( - (/^\d/.test(text) ? 'Must begin with a letter' : undefined)} - onSubmit={vi.fn()} - aria-label="Node name" - /> - ); - - await user.click(screen.getByRole('button', { name: /^Node name/ })); - await user.clear(screen.getByRole('textbox', { name: 'Node name' })); - await user.keyboard('2fa'); - - expect(screen.getByText('Must begin with a letter')).toBeInTheDocument(); - expect(screen.getByRole('textbox', { name: 'Node name' })).toHaveAttribute( - 'aria-invalid', - 'true' - ); - - await user.keyboard('{Escape}'); - - // Escape reverts the text, so the owner's message goes with it. - expect(screen.queryByText('Must begin with a letter')).not.toBeInTheDocument(); - }); - - it('keeps keystrokes from reaching an ancestor while editing', async () => { - const user = userEvent.setup(); - const onAncestorKeyDown = vi.fn(); - render( -
- -
- ); - - await user.click(screen.getByRole('button', { name: /^Node name/ })); - await user.keyboard('x{Escape}'); - - expect(onAncestorKeyDown).not.toHaveBeenCalled(); - }); - - it('follows an external value change, editing or not', async () => { - const user = userEvent.setup(); - const { rerender } = render( - - ); - rerender( - - ); - expect(screen.getByRole('button', { name: /^Node name/ })).toHaveTextContent('Finish'); - - await user.click(screen.getByRole('button', { name: /^Node name/ })); - rerender( - - ); - expect(screen.getByRole('textbox', { name: 'Node name' })).toHaveValue('Wrap up'); - }); - - it('announces the value through the trigger, not only the field name', () => { - render(); - expect(screen.getByRole('button', { name: 'Node name: End' })).toBeInTheDocument(); - }); - - describe('multiline', () => { - it('inserts a newline on Shift+Enter, submits the multi-line value on a plain Enter', async () => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - render(); - - await user.click(screen.getByRole('button', { name: /^Description/ })); - await user.keyboard('{End}{Shift>}{Enter}{/Shift}Second'); - expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue('First\nSecond'); - expect(onSubmit).not.toHaveBeenCalled(); - - await user.keyboard('{Enter}'); - expect(onSubmit).toHaveBeenCalledExactlyOnceWith('First\nSecond'); - }); - - it('collapses newlines and submits on Enter in wrap mode', async () => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - render( - - ); - - await user.click(screen.getByRole('button', { name: /^Description/ })); - await user.keyboard('{End} draft{Shift>}{Enter}{/Shift}'); - expect(onSubmit).toHaveBeenCalledWith('First draft'); - - await user.click(screen.getByRole('button', { name: /^Description/ })); - await user.keyboard('{End}'); - await user.paste('\nSecond'); - expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue( - 'First draft Second' - ); - }); - - it.each([ - [true], - ['wrap'] as const, - ])('caps the read clamp and the editor height at maxLines (multiline=%s)', async (multiline) => { - const user = userEvent.setup(); - render( - - ); - - const trigger = screen.getByRole('button', { name: /^Description/ }); - expect(trigger).toHaveStyle({ '--editable-text-lines': '2' }); - - await user.click(trigger); - // 2 lines at the `lg` line box (20px) plus the editor's vertical padding. - expect(screen.getByRole('textbox', { name: 'Description' })).toHaveStyle({ - maxHeight: '44px', - }); - }); }); }); diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.tsx index 082f2afa9..7133ab365 100644 --- a/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.tsx +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/EditableText.tsx @@ -1,262 +1,11 @@ -import { cn, FormFieldError } from '@uipath/apollo-wind'; -import { type ReactNode, useCallback, useId, useRef, useState } from 'react'; -import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; -import { CanvasTooltip } from '../CanvasTooltip'; +import { cn, type EditableTextProps, EditableText as WindEditableText } from '@uipath/apollo-wind'; -export interface EditableTextProps { - /** The text on screen, in both modes: this is a controlled field, so an edit shows up only once `onChange` has been applied here. */ - value: string; - placeholder?: string; - size?: 'sm' | 'lg'; - /** - * Text layout. `true` accepts newlines (Shift+Enter); `'wrap'` wraps across lines but keeps the - * value single-line, so every Enter commits and pasted newlines collapse. Enter always commits. - */ - multiline?: boolean | 'wrap'; - /** Visible-line ceiling when `multiline`. The editor scrolls past it. Defaults to 3. */ - maxLines?: number; - /** Fires on every keystroke with the text as typed, like any controlled input. Pair with `onSubmit`: both are required to make the text editable, and `value` has to follow this callback or the field won't accept input. Escape reports the text held when the editor opened, so the owner reverts through this same path. */ - onChange?: (next: string) => void; - /** Fires on Enter or blur with the trimmed `value`, and only when it differs from the text the editor opened with, so a click in and straight back out is silent. The owner decides whether to persist it. */ - onSubmit?: (next: string) => void; - /** Locks the text as-is: no edit mode, no commit. Ignored for static text. */ - disabled?: boolean; - /** Field-specific feedback rendered immediately below the text. */ - error?: ReactNode; - /** Names the editor and its click target. Unused for static text. */ - 'aria-label'?: string; - className?: string; - 'data-testid'?: string; -} - -const stripNewlines = (text: string) => text.replace(/\n+/g, ' '); - -const READ_CLASS = { - lg: 'text-base font-semibold leading-5 tracking-[-0.3px] text-foreground', - sm: 'text-xs leading-4 text-foreground-muted', -} as const; - -const EDIT_CLASS = { - lg: 'text-base font-semibold leading-5 tracking-[-0.3px]', - sm: 'text-xs leading-4', -} as const; - -/** Per-size line box, matching the `leading-*` above, so a line cap needs no measuring. */ -const LINE_HEIGHT = { lg: 20, sm: 16 } as const; - -/** Vertical padding of `INTERACTIVE_CLASS` (`py-0.5`), added to the editor's line cap. */ -const VERTICAL_PADDING = 4; - -/** - * Multi-line truncation, the same `line-clamp` idiom the canvas node label uses, with the count - * fed through a CSS variable so the class stays static. `wrap-break-word` is load-bearing: an - * unbroken token would otherwise run past the box and be cut mid-glyph, since the clamp can only - * ellipsize at a line boundary. `line-clamp` sets its own `display`, so no `block` alongside it. - */ -const CLAMP_CLASS = 'line-clamp-[var(--editable-text-lines)] whitespace-pre-line wrap-break-word'; - -/** Padding the ring needs to sit off the glyphs; the negative margin keeps text aligned across modes. */ -const INTERACTIVE_CLASS = 'rounded px-1.5 py-0.5 -mx-1.5'; - -const ERROR_RING_CLASS = 'ring-1 ring-error'; +export type { EditableTextProps }; /** - * Click-to-edit text for the node identity row: enters on click, submits on Enter or blur, reverts - * on Escape. Controlled — the owner holds the text and feeds it back through `value`, which lets it - * validate what is being typed and render the message through `error`. Renders static text unless - * both `onChange` and `onSubmit` are given. + * apollo-wind's EditableText, kept out of React Flow's way: dragging or scrolling over the text + * neither pans nor zooms the canvas behind it. */ -export function EditableText({ - value, - placeholder, - size = 'lg', - multiline, - maxLines = 3, - onChange, - onSubmit, - disabled, - error, - 'aria-label': ariaLabel, - className, - 'data-testid': dataTestId, -}: EditableTextProps) { - const allowNewlines = multiline === true; - const editable = !!onChange && !!onSubmit; - const [isEditing, setIsEditing] = useState(false); - const inputRef = useRef(null); - // What Escape reverts to. Taken when the editor opens, since by then `value` - // is the owner's live state and no longer remembers where the edit started. - const openedWith = useRef(value); - const errorId = `editable-text-${useId().replace(/:/g, '')}-error`; - - const hasError = !!error; - // `aria-invalid` is only valid on the widget renders; a static span takes the - // description alone (role=generic does not support the state). - const describedBy = hasError ? errorId : undefined; - - useIsomorphicLayoutEffect(() => { - // Going disabled mid-edit closes the editor (below) without submitting. The - // text itself is the owner's, so re-enabling shows whatever it kept. - if (disabled) return setIsEditing(false); - if (!isEditing) return; - inputRef.current?.focus(); - inputRef.current?.select(); - }, [isEditing, disabled]); - - const commit = useCallback(() => { - setIsEditing(false); - const next = value.trim(); - if (next !== value) onChange?.(next); - // Opening and closing without touching the text is not an edit: staying - // silent keeps the owner from persisting a value it already has. - if (next !== openedWith.current) onSubmit?.(next); - }, [value, onChange, onSubmit]); - - const cancel = useCallback(() => { - setIsEditing(false); - if (openedWith.current !== value) onChange?.(openedWith.current); - }, [value, onChange]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // Only `multiline` (not `'wrap'`) lets Shift+Enter fall through to a newline; - // every other Enter commits. - if (e.key === 'Enter' && !(allowNewlines && e.shiftKey)) { - e.preventDefault(); - commit(); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancel(); - } - e.stopPropagation(); - }, - [commit, cancel, allowNewlines] - ); - - // Read mode caps the rendered lines through the clamp; edit mode grows with - // content up to the same height, then scrolls. - const editorMaxHeight = multiline - ? { maxHeight: maxLines * LINE_HEIGHT[size] + VERTICAL_PADDING } - : undefined; - const readClampStyle = multiline - ? ({ '--editable-text-lines': maxLines } as React.CSSProperties) - : undefined; - const readClampClass = multiline ? CLAMP_CLASS : 'block truncate'; - - const message = ( - - {error} - - ); - - if (!editable) { - return ( - <> - - - {value || placeholder} - - - {message} - - ); - } - - if (isEditing && !disabled) { - const sharedProps = { - 'data-slot': 'editable-text-input', - 'data-testid': dataTestId ? `${dataTestId}-input` : undefined, - value, - placeholder, - 'aria-label': ariaLabel, - 'aria-describedby': describedBy, - 'aria-errormessage': hasError ? errorId : undefined, - 'aria-invalid': hasError || undefined, - onKeyDown: handleKeyDown, - onBlur: commit, - className: cn( - 'nodrag nowheel w-full min-w-0 border-none bg-surface-overlay text-foreground outline-none ring-1', - hasError ? 'ring-error' : 'ring-brand', - INTERACTIVE_CLASS, - EDIT_CLASS[size], - className - ), - } as const; - - return ( - <> - {multiline ? ( -