From 64678cf49350025a257247278e2786d3026fd2d5 Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Wed, 21 Oct 2026 11:43:14 -0700 Subject: [PATCH] feat(apollo-wind): EditableText, and an editable label in QuickFormField [MST-16146] EditableText is click-to-edit text for a name or title, ported from apollo-react's canvas EditableText: a controlled field that enters on click, submits the trimmed text on Enter or blur, and reverts on Escape, with multiline, error and disabled states. It submits whatever was committed, empty text included, and leaves rules about it to the owner. The wind version adds: - `size="label"`, matching a form field label; - `editing` and `onEditingChange`, so an owner can hold whether the editor is open, such as to open it for an item just added; - `truncationTooltip` (on by default), which shows the full text on hover while it is cut off, measured when the tooltip would open, as AttachmentTitle does; - a read-trigger cap of 100% plus its negative margins, so text that fits a content-sized flex row is not truncated; - an editor that hangs out on its leading side only, so what follows it, such as an asterisk, sits outside its box. QuickFormField's header gains: - `onLabelChange`: a string `label` is then edited in place with EditableText, with the `required` asterisk beside it, so the header's Required switch is what toggles it. Every commit is passed through, empty included; a consumer rejects one by leaving `label` as it was; - `isEditingLabel` and `onIsEditingLabelChange`, forwarding the controlled editing state; - `tooltip`: an InfoTooltip after the label and its asterisk, as FormFieldHeader places it, for every kind of label; - `labelAddon`, rendered before any label, such as a drag handle. While the label is edited, it fills the header: the actions and the info icon step aside until the edit is done, so a narrow header still has room to type. New strings: `labelAriaLabel`, `labelPlaceholder` and `tooltipAriaLabel`. Co-Authored-By: Claude Opus 5.5 --- packages/apollo-wind/apollo-ai-context.md | 2 +- .../apollo-wind/docs/AI_CONSUMER_GUIDE.md | 1 + .../components/ui/editable-text.stories.tsx | 206 +++++++ .../src/components/ui/editable-text.test.tsx | 514 ++++++++++++++++++ .../src/components/ui/editable-text.tsx | 378 +++++++++++++ .../apollo-wind/src/components/ui/index.ts | 1 + .../components/field-header.tsx | 60 +- .../quick-form-field.stories.tsx | 48 +- .../quick-form-field.test.tsx | 189 +++++++ .../ui/quick-form-field/quick-form-field.tsx | 38 +- .../components/ui/quick-form-field/types.ts | 39 +- packages/apollo-wind/src/index.ts | 2 + 12 files changed, 1471 insertions(+), 7 deletions(-) create mode 100644 packages/apollo-wind/src/components/ui/editable-text.stories.tsx create mode 100644 packages/apollo-wind/src/components/ui/editable-text.test.tsx create mode 100644 packages/apollo-wind/src/components/ui/editable-text.tsx 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 ? ( +