diff --git a/packages/apollo-wind/apollo-ai-context.md b/packages/apollo-wind/apollo-ai-context.md index 5ae8f9f9c..b67e3a95d 100644 --- a/packages/apollo-wind/apollo-ai-context.md +++ b/packages/apollo-wind/apollo-ai-context.md @@ -365,7 +365,7 @@ PromptSuggestions, RadioGroup, Resizable, ScrollArea, Search, Separator, Sidebar, Skeleton, Slider, Sonner, Spinner, StatsCard, Stepper, Switch, Toggle, ToggleGroup, Tooltip, InfoTooltip, FormField (with FormFieldLabel, FormFieldHeader, FormFieldDescription, FormFieldError), -BooleanRadioGroup (True / False / unset), VariablePicker, PromptEditor, +BooleanRadioGroup (True / False / unset), VariablePicker, PromptEditor, EditableText, VariableValueControl, PromptValueControl, QuickFormField (only inside a Quick Form builder, where end users configure their own fields) diff --git a/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md b/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md index f5a2b9f43..fd9d3ca11 100644 --- a/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md +++ b/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md @@ -144,6 +144,7 @@ Same props as Row. | `PromptValueControl` | Prompt for an agent | `value`, `onChange`, `strings`. Render in `InputGroup` with `variant="agent"` `layout="grow"` | | `QuickFormField` | Quick Form builder field | Only where end users configure fields in a HITL Quick Form. `fieldTypes`, `valueModes`, `confirmModeChange`, `renderModeControl`, `variables`, `strings` | | `Label` | Bare label primitive | `htmlFor`. For a field use `FormFieldLabel` | +| `EditableText` | Click-to-edit name/title | `value`, `onChange`, `onSubmit`, `size`, `editing`/`onEditingChange`. Controlled: `value` must follow `onChange` | | **Data Display** | | | | `Card` | Container card | Children: `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | | `StatsCard` | Metrics card | `title`, `value`, `trend`, `description`, `icon`, `variant` | diff --git a/packages/apollo-wind/src/components/ui/editable-text.stories.tsx b/packages/apollo-wind/src/components/ui/editable-text.stories.tsx new file mode 100644 index 000000000..aa5cbbb7e --- /dev/null +++ b/packages/apollo-wind/src/components/ui/editable-text.stories.tsx @@ -0,0 +1,206 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { EditableText, type EditableTextProps } from './editable-text'; + +const meta: Meta = { + title: 'Components/Core/Editable Text', + component: EditableText, + parameters: { + docs: { + description: { + component: ` +**EditableText** is click-to-edit text for a name or title, such as a field label in a form +builder. 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 +it renders static text with no interactive affordance. + +These props shape how the text behaves: + +- \`multiline\`: \`false\` (default) keeps a single-line input that truncates when collapsed. + \`true\` renders a textarea that accepts newlines on Shift+Enter. \`'wrap'\` renders the same + textarea layout but keeps the value single-line: every Enter commits and pasted newlines + collapse to spaces. +- \`size\`: \`label\` matches a form field label, \`sm\` a secondary line, \`lg\` (default) a title. +- \`truncationTooltip\`: on by default, shows the full text on hover while it is cut off. +- \`editing\` with \`onEditingChange\`: lets the owner hold whether the editor is open, such as to open it for an item just added. +- \`disabled\`: locks the text as-is. The read trigger stops responding to clicks, and disabling + during an edit closes the editor without submitting. +- \`error\`: any feedback, rendered below the text with a ring. Since the owner holds the text, it + can derive this from what is being typed or from an async check on the persisted value. + `, + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** Owns the text, as any consumer of a controlled field must. */ +function Live({ + initial, + ...props +}: { initial: string } & Omit) { + const [value, setValue] = useState(initial); + return {}} {...props} value={value} onChange={setValue} />; +} + +const Case = ({ + label, + hint, + children, +}: { + label: string; + hint: string; + children: React.ReactNode; +}) => ( +
+
+ {label} +
+
{hint}
+
{children}
+
+); + +const Frame = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +const LONG_TEXT = + 'Fetch the customer record, enrich it with the latest invoice totals, then hand it to the approval step'; + +export const Playground: Story = { + args: { + placeholder: 'Untitled', + size: 'lg', + multiline: false, + maxLines: 3, + disabled: false, + 'aria-label': 'Name', + }, + argTypes: { + multiline: { control: 'inline-radio', options: [false, true, 'wrap'] }, + size: { control: 'inline-radio', options: ['label', 'sm', 'lg'] }, + maxLines: { control: { type: 'number', min: 1, max: 6 } }, + }, + render: (args) => ( + + + + ), +}; + +export const MultilineModes: Story = { + name: 'Multiline settings', + render: () => ( + + + + + + + + + + + + + + + + + + ), +}; + +export const Sizes: Story = { + name: 'Sizes', + render: () => ( + + + + + + + + + + + + + + ), +}; + +export const DisabledStates: Story = { + name: 'Disabled settings', + render: () => ( + + + + + + + + + + + + + + + + + + + + + + ), +}; diff --git a/packages/apollo-wind/src/components/ui/editable-text.test.tsx b/packages/apollo-wind/src/components/ui/editable-text.test.tsx new file mode 100644 index 000000000..4bde1ca11 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/editable-text.test.tsx @@ -0,0 +1,514 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { EditableText, type EditableTextProps } from './editable-text'; + +/** 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) { + const [text, setText] = useState(initial); + + return ( + { + onChangeSpy?.(next); + setText(next); + }} + error={props.error ?? validate?.(text)} + /> + ); +} + +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(); + // aria-disabled, not disabled, so the trigger stays focusable and its text readable. + expect(trigger).toHaveAttribute('aria-disabled', 'true'); + 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 () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^Node name/ })); + + 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); + }); + + 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('submits the trimmed value on Enter and closes', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: /^Node name/ })); + 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('controlled editing', () => { + /** Holds `editing` the way an owner does, so the editor opens and closes through it. */ + function OwnedEditing({ + initialEditing, + onEditingSpy, + ...props + }: { initialEditing: boolean; onEditingSpy?: (editing: boolean) => void } & Omit< + EditableTextProps, + 'value' | 'onChange' | 'editing' | 'onEditingChange' + > & { initial: string }) { + const [editing, setEditing] = useState(initialEditing); + return ( + { + onEditingSpy?.(next); + setEditing(next); + }} + /> + ); + } + + it('opens focused with the text selected when the owner says so', () => { + render( + + ); + const input = screen.getByRole('textbox', { name: 'Node name' }); + expect(input).toHaveFocus(); + expect((input as HTMLInputElement).selectionStart).toBe(0); + expect((input as HTMLInputElement).selectionEnd).toBe(3); + }); + + it('asks the owner to open on click and to close on commit', async () => { + const user = userEvent.setup(); + const onEditingSpy = vi.fn(); + const onSubmit = vi.fn(); + render( + + ); + await user.click(screen.getByRole('button', { name: /^Node name/ })); + expect(onEditingSpy).toHaveBeenLastCalledWith(true); + await user.keyboard('Finish{Enter}'); + expect(onEditingSpy).toHaveBeenLastCalledWith(false); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith('Finish'); + }); + + it('stays closed when the owner ignores the request', async () => { + const user = userEvent.setup(); + const onEditingChange = vi.fn(); + render( + + ); + await user.click(screen.getByRole('button', { name: /^Node name/ })); + expect(onEditingChange).toHaveBeenCalledWith(true); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('reverts on Escape to the text shown when the owner opened it', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render( + + ); + await user.keyboard('Finish{Escape}'); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Node name: End' })).toBeInTheDocument(); + }); + + it('reverts to the text shown when the owner opens it later, not at mount', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const props = { onChange, onSubmit: vi.fn(), 'aria-label': 'Node name' }; + const { rerender } = render(); + rerender(); + await user.keyboard('{Escape}'); + // Nothing was typed, so the text it opened with is what is shown: no revert to report. + expect(onChange).not.toHaveBeenCalled(); + rerender(); + await user.keyboard('{Escape}'); + expect(onChange).toHaveBeenLastCalledWith('Finish'); + }); + + it('asks to close when disabled mid-edit, so it does not reopen when re-enabled', () => { + const onEditingChange = vi.fn(); + const props = { + value: 'End', + editing: true, + onEditingChange, + onChange: vi.fn(), + onSubmit: vi.fn(), + 'aria-label': 'Node name', + }; + const { rerender } = render(); + rerender(); + expect(onEditingChange).toHaveBeenCalledWith(false); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('never opens static text', () => { + render(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + }); + + // A trailing negative margin would let whatever follows the editor, such as a required asterisk, + // sit over its filled box. + it('hangs the editor out on the leading side only', async () => { + const user = userEvent.setup(); + render(); + expect(screen.getByRole('button', { name: /^Field label/ })).toHaveClass('-mx-1.5'); + await user.click(screen.getByRole('button', { name: /^Field label/ })); + const input = screen.getByRole('textbox', { name: 'Field label' }); + expect(input).toHaveClass('-ml-1.5'); + expect(input).not.toHaveClass('-mx-1.5'); + }); + + it('sets a field label in the label size', () => { + render( + + ); + expect(screen.getByRole('button', { name: /^Field label/ })).toHaveClass( + 'text-xs', + 'font-medium', + 'text-foreground' + ); + }); + + it('has no accessibility violations in read and edit mode', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + expect(await axe(container)).toHaveNoViolations(); + + await user.click(screen.getByRole('button', { name: /^Node name/ })); + expect(await axe(container)).toHaveNoViolations(); + }); + + describe('truncation tooltip', () => { + // jsdom lays nothing out, so the trigger is cut off only when these say so. + const cutOff = (cut: boolean) => { + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(cut ? 200 : 100); + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100); + }; + afterEach(() => vi.restoreAllMocks()); + + it('shows the full text on hover while it is cut off', async () => { + cutOff(true); + const user = userEvent.setup(); + render(); + await user.hover(screen.getByRole('button', { name: /^Name/ })); + expect(await screen.findByRole('tooltip', {}, { timeout: 2000 })).toHaveTextContent( + 'Enrich customer record' + ); + }); + + it('shows nothing while the text fits', async () => { + cutOff(false); + const user = userEvent.setup(); + render(); + await user.hover(screen.getByRole('button', { name: /^Name/ })); + await new Promise((resolve) => setTimeout(resolve, 900)); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('covers static text too', async () => { + cutOff(true); + const user = userEvent.setup(); + render(); + await user.hover(screen.getByText('Read-only label')); + expect(await screen.findByRole('tooltip', {}, { timeout: 2000 })).toHaveTextContent( + 'Read-only label' + ); + }); + + it('can be turned off', async () => { + cutOff(true); + const user = userEvent.setup(); + render( + + ); + await user.hover(screen.getByRole('button', { name: /^Name/ })); + await new Promise((resolve) => setTimeout(resolve, 900)); + expect(screen.queryByRole('tooltip')).not.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-wind/src/components/ui/editable-text.tsx b/packages/apollo-wind/src/components/ui/editable-text.tsx new file mode 100644 index 000000000..ad7c97410 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/editable-text.tsx @@ -0,0 +1,378 @@ +import * as React from 'react'; +import { FormFieldError } from '@/components/ui/form-field'; +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib'; + +const useIsomorphicLayoutEffect = + typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; + +/** `label` matches a field label; `sm` and `lg` a secondary line and a title. */ +export type EditableTextSize = 'label' | 'sm' | 'lg'; + +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; + /** Defaults to `lg`. */ + size?: EditableTextSize; + /** + * 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; + /** + * Whether the editor is open, for an owner that holds that state: it opens focused with the text + * selected, and asks to close through `onEditingChange`. Omit to let the component hold it. + */ + editing?: boolean; + /** + * Called when the editor asks to open (a click) or close (commit, Escape, or going disabled). + * Required alongside `editing` for the editor to open or close at all. + */ + onEditingChange?: (editing: boolean) => 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?: React.ReactNode; + /** Show the full text in a tooltip on hover when it is cut off. On by default. */ + truncationTooltip?: boolean; + /** 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 = { + label: 'text-xs font-medium leading-4 text-foreground', + 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 = { + label: 'text-xs font-medium leading-4', + 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 = { label: 16, 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, 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'; + +/** + * The editor keeps only the leading negative margin: its filled box takes its full width in a row, + * so whatever follows, such as a required asterisk, sits after the box rather than over it. + */ +const EDITOR_SPACING_CLASS = 'rounded px-1.5 py-0.5 -ml-1.5'; + +/** + * The read trigger hangs out by its negative margins, so its cap is the container plus them. A + * plain `max-w-full` would take its padding out of the text whenever the container is sized to the + * text, as a flex row is, and truncate text that fits. + */ +const READ_MAX_WIDTH_CLASS = 'max-w-[calc(100%+--spacing(3))]'; + +const ERROR_RING_CLASS = 'ring-1 ring-error'; + +/** + * The full text on hover, only while the trigger cuts it off: by width when it truncates, or by + * height when it clamps lines. Measured on every open, so a resize needs no observer. Radix exposes + * no way to detect a provider above, so each one brings its own (a context, no DOM). + */ +function TruncationTooltip({ + content, + multiline, + children, +}: { + content: React.ReactNode; + multiline: boolean; + children: React.ReactElement; +}) { + const triggerRef = React.useRef(null); + // A callback, since the trigger is a button or, for static text, a span. + const setTrigger = React.useCallback((node: HTMLElement | null) => { + triggerRef.current = node; + }, []); + const [open, setOpen] = React.useState(false); + return ( + + { + const node = triggerRef.current; + setOpen( + next && + !!node && + (node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight) + ); + }} + > + + {children} + + + + {content} + + + + + ); +} + +/** + * Click-to-edit text, such as a name or title: 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. Text that is cut off shows in full in a tooltip. + */ +export function EditableText({ + value, + placeholder, + size = 'lg', + multiline, + maxLines = 3, + onChange, + onSubmit, + editing: editingProp, + onEditingChange, + disabled, + truncationTooltip = true, + error, + 'aria-label': ariaLabel, + className, + 'data-testid': dataTestId, +}: EditableTextProps) { + const allowNewlines = multiline === true; + const editable = !!onChange && !!onSubmit; + const [editingState, setEditingState] = React.useState(false); + const controlled = editingProp !== undefined; + const requested = controlled ? editingProp : editingState; + const isEditing = requested && editable && !disabled; + const setEditing = React.useCallback( + (next: boolean) => { + if (!controlled) setEditingState(next); + onEditingChange?.(next); + }, + [controlled, onEditingChange] + ); + const inputRef = React.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 = React.useRef(value); + const latestValue = React.useRef(value); + latestValue.current = value; + const errorId = `editable-text-${React.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; + + // Going disabled mid-edit closes the editor without submitting, and says so, so it does not + // reopen when re-enabled. The text itself is the owner's, so it keeps whatever it held. + useIsomorphicLayoutEffect(() => { + if (disabled && requested) setEditing(false); + }, [disabled, requested, setEditing]); + + // However the editor opened (a click, or the owner), it opens on the text shown now. + useIsomorphicLayoutEffect(() => { + if (!isEditing) return; + openedWith.current = latestValue.current; + inputRef.current?.focus(); + inputRef.current?.select(); + }, [isEditing]); + + const cancel = React.useCallback(() => { + setEditing(false); + if (openedWith.current !== value) onChange?.(openedWith.current); + }, [value, onChange, setEditing]); + + const commit = React.useCallback(() => { + const next = value.trim(); + setEditing(false); + 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, setEditing]); + + const handleKeyDown = React.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 withTooltip = (trigger: React.ReactElement) => + truncationTooltip && (value || placeholder) ? ( + + {trigger} + + ) : ( + trigger + ); + + const message = ( + + {error} + + ); + + if (!editable) { + return ( + <> + {withTooltip( + + {value || placeholder} + + )} + {message} + + ); + } + + if (isEditing) { + 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( + 'w-full min-w-0 border-none bg-surface-overlay text-foreground outline-none ring-1', + hasError ? 'ring-error' : 'ring-brand', + EDITOR_SPACING_CLASS, + EDIT_CLASS[size], + className + ), + } as const; + + return ( + <> + {multiline ? ( +