From 8fa457cae36813c5c7d552d816ab2abc7abdb23a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 07:58:57 +0000 Subject: [PATCH] 0.76.0 - Add InlineEdit component for click-to-edit text fields Adds a new InlineEdit primitive that swaps a label for an input on click, Enter, Space, or F2. Supports single-line and multi-line modes, async onSave with loading and error states, optional confirm/cancel buttons, commit-on-blur, and the standard size and variant tokens (default, ghost, underline). Storybook coverage includes AllVariants, AllSizes, AsyncSave, Loading, WithError, Multiline, an InsideResourceCard composition, and play() interaction tests for the edit and cancel flows. --- package.json | 2 +- src/components/ui/index.ts | 3 + .../ui/inline-edit/InlineEdit.stories.tsx | 384 ++++++++++++++ src/components/ui/inline-edit/index.ts | 18 + .../ui/inline-edit/inline-edit-variants.ts | 13 + src/components/ui/inline-edit/inline-edit.tsx | 484 ++++++++++++++++++ 6 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/inline-edit/InlineEdit.stories.tsx create mode 100644 src/components/ui/inline-edit/index.ts create mode 100644 src/components/ui/inline-edit/inline-edit-variants.ts create mode 100644 src/components/ui/inline-edit/inline-edit.tsx diff --git a/package.json b/package.json index 1315b1c..2ab91bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.75.0", + "version": "0.76.0", "private": false, "license": "UNLICENSED", "description": "React.js UI components for SchemaVaults frontend applications", diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 24113a6..faf4957 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -302,3 +302,6 @@ export type * from "./http-method-badge"; export * from "./browser-frame"; export type * from "./browser-frame"; + +export * from "./inline-edit"; +export type * from "./inline-edit"; diff --git a/src/components/ui/inline-edit/InlineEdit.stories.tsx b/src/components/ui/inline-edit/InlineEdit.stories.tsx new file mode 100644 index 0000000..1d6b766 --- /dev/null +++ b/src/components/ui/inline-edit/InlineEdit.stories.tsx @@ -0,0 +1,384 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { + useEffect, + useState, + type ComponentProps, + type ReactElement, +} from "react"; + +import { InlineEdit } from "./inline-edit"; +import { + inlineEditSizeIds, + inlineEditVariantIds, +} from "./inline-edit-variants"; + +const meta = { + title: "Components/InlineEdit", + component: InlineEdit, + parameters: { + layout: "centered", + docs: { + description: { + component: + "A click-to-edit text field for editing values in place. Renders as " + + "the current label until the user activates it (click, Enter, Space, " + + "or F2), at which point it swaps in an input with confirm/cancel " + + "actions. Supports single-line and multi-line modes, async save " + + "handlers with loading and error states, and the standard SchemaVaults " + + "size and variant tokens.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + variant: { + options: inlineEditVariantIds, + control: { type: "radio" }, + }, + size: { + options: inlineEditSizeIds, + control: { type: "radio" }, + }, + value: { control: { type: "text" } }, + placeholder: { control: { type: "text" } }, + disabled: { control: { type: "boolean" } }, + multiline: { control: { type: "boolean" } }, + showActions: { control: { type: "boolean" } }, + showEditIcon: { control: { type: "boolean" } }, + commitOnBlur: { control: { type: "boolean" } }, + loading: { control: { type: "boolean" } }, + error: { control: { type: "text" } }, + }, + args: { + value: "Production database", + placeholder: "Click to edit", + variant: "default", + size: "default", + disabled: false, + multiline: false, + showActions: true, + showEditIcon: true, + commitOnBlur: true, + onValueChange: fn(), + onCancel: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function ControlledStory( + props: Omit, "value"> + & { initialValue?: string }, +): ReactElement { + const { initialValue = "Production database", onValueChange, ...rest } = props; + const [value, setValue] = useState(initialValue); + return ( + { + setValue(next); + onValueChange?.(next, trigger); + }} + /> + ); +} + +export const Default: Story = { + render: (args): ReactElement => , +}; + +export const Ghost: Story = { + args: { variant: "ghost" }, + render: (args): ReactElement => , +}; + +export const Underline: Story = { + args: { variant: "underline" }, + render: (args): ReactElement => , +}; + +export const Empty: Story = { + args: { placeholder: "Add a description…" }, + render: (args): ReactElement => ( + + ), +}; + +export const Disabled: Story = { + args: { disabled: true }, + render: (args): ReactElement => , +}; + +export const WithoutActions: Story = { + args: { + showActions: false, + placeholder: "Press Enter to save", + }, + render: (args): ReactElement => , +}; + +export const WithoutEditIcon: Story = { + args: { showEditIcon: false }, + render: (args): ReactElement => , +}; + +export const MaxLengthLimited: Story = { + args: { maxLength: 24 }, + render: (args): ReactElement => ( +
+ + + Limited to 24 characters via maxLength. + +
+ ), +}; + +export const Multiline: Story = { + args: { + multiline: true, + placeholder: "Add notes…", + inputAriaLabel: "Workstream notes", + }, + render: (args): ReactElement => ( +
+ +

+ Use Enter{" "} + for a new line and{" "} + ⌘/Ctrl+ + Enter{" "} + to save. +

+
+ ), +}; + +function AsyncSaveStory(): ReactElement { + const [value, setValue] = useState("vault-prod-eu-west-1"); + const [savedAt, setSavedAt] = useState(null); + + return ( +
+ + Vault name + + => { + await new Promise((resolve): number => + window.setTimeout(resolve, 700), + ); + if (next.trim().length < 3) { + throw new Error("Name must be at least 3 characters."); + } + setValue(next); + setSavedAt(new Date().toISOString()); + }} + /> + + {savedAt === null + ? "Try editing — saves resolve after 700ms; names shorter than 3 chars throw." + : `Saved at ${savedAt}`} + +
+ ); +} + +export const AsyncSave: Story = { + render: (): ReactElement => , + parameters: { + docs: { + description: { + story: + "Pass an `onSave` async handler to coordinate persistence. While the " + + "promise is in flight the editor disables and dims; if it rejects the " + + "error message is surfaced inline and the editor stays open so the " + + "user can correct their input.", + }, + }, + }, +}; + +export const Loading: Story = { + args: { loading: true, defaultEditing: true }, + render: (args): ReactElement => , +}; + +export const WithError: Story = { + args: { + defaultEditing: true, + error: "That name is already taken in this workspace.", + }, + render: (args): ReactElement => , +}; + +export const AllVariants: Story = { + render: (): ReactElement => ( +
+ {inlineEditVariantIds.map((variant) => ( +
+ + {variant} + + +
+ ))} +
+ ), +}; + +export const AllSizes: Story = { + render: (): ReactElement => ( +
+ {inlineEditSizeIds.map((size) => ( +
+ + {size} + + +
+ ))} +
+ ), +}; + +function ResourceCardStory(): ReactElement { + const [name, setName] = useState("Customer billing exports"); + const [description, setDescription] = useState( + "Nightly dump of invoiced charges with PII redacted for downstream analytics.", + ); + + return ( +
+
+ Vault +
+ setName(next)} + /> + setDescription(next)} + /> +
+ ); +} + +export const InsideResourceCard: Story = { + render: (): ReactElement => , + parameters: { + docs: { + description: { + story: + "Typical SchemaVaults pattern: a resource header where the vault's " + + "name and description are both editable in place without opening a " + + "modal.", + }, + }, + }, +}; + +function EditFlowControlled(): ReactElement { + const [value, setValue] = useState("initial value"); + + useEffect((): void => { + // No-op: ensures controlled state is preserved across re-renders during + // the interaction test. + }, [value]); + + return ( +
+ setValue(next)} + /> + + Current value: {value} + +
+ ); +} + +export const EditFlowInteraction: Story = { + render: (): ReactElement => , + play: async ({ canvasElement }): Promise => { + const canvas = within(canvasElement); + + const trigger = await canvas.findByRole("button", { + name: "Inline edit interaction target", + }); + await userEvent.click(trigger); + + const input = await canvas.findByLabelText( + "Inline edit interaction target", + ); + await userEvent.clear(input); + await userEvent.type(input, "updated value"); + + const confirm = await canvas.findByRole("button", { name: "Save" }); + await userEvent.click(confirm); + + await waitFor((): void => { + expect(canvas.getByTestId("inline-edit-current-value")).toHaveTextContent( + "Current value: updated value", + ); + }); + }, +}; + +export const CancelInteraction: Story = { + render: (): ReactElement => , + play: async ({ canvasElement }): Promise => { + const canvas = within(canvasElement); + + const trigger = await canvas.findByRole("button", { + name: "Inline edit interaction target", + }); + await userEvent.click(trigger); + + const input = await canvas.findByLabelText( + "Inline edit interaction target", + ); + await userEvent.clear(input); + await userEvent.type(input, "dropped value{Escape}"); + + await waitFor((): void => { + expect(canvas.getByTestId("inline-edit-current-value")).toHaveTextContent( + "Current value: initial value", + ); + }); + }, +}; diff --git a/src/components/ui/inline-edit/index.ts b/src/components/ui/inline-edit/index.ts new file mode 100644 index 0000000..87aa2e9 --- /dev/null +++ b/src/components/ui/inline-edit/index.ts @@ -0,0 +1,18 @@ +export { + InlineEdit, + InlineEdit as default, + inlineEditDisplayVariants, + inlineEditInputVariants, +} from "./inline-edit"; +export type { + InlineEditCommitTrigger, + InlineEditProps, +} from "./inline-edit"; +export { + inlineEditSizeIds, + inlineEditVariantIds, +} from "./inline-edit-variants"; +export type { + InlineEditSize, + InlineEditVariant, +} from "./inline-edit-variants"; diff --git a/src/components/ui/inline-edit/inline-edit-variants.ts b/src/components/ui/inline-edit/inline-edit-variants.ts new file mode 100644 index 0000000..35c3c3c --- /dev/null +++ b/src/components/ui/inline-edit/inline-edit-variants.ts @@ -0,0 +1,13 @@ +export const inlineEditVariantIds = [ + "default", + "ghost", + "underline", +] as const satisfies readonly string[]; +export type InlineEditVariant = (typeof inlineEditVariantIds)[number]; + +export const inlineEditSizeIds = [ + "sm", + "default", + "lg", +] as const satisfies readonly string[]; +export type InlineEditSize = (typeof inlineEditSizeIds)[number]; diff --git a/src/components/ui/inline-edit/inline-edit.tsx b/src/components/ui/inline-edit/inline-edit.tsx new file mode 100644 index 0000000..cb92119 --- /dev/null +++ b/src/components/ui/inline-edit/inline-edit.tsx @@ -0,0 +1,484 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; +import { Check, Pencil, X } from "lucide-react"; +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ChangeEvent, + type FocusEvent, + type HTMLAttributes, + type KeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactElement, + type ReactNode, + type Ref, +} from "react"; + +import { cn } from "@/lib/utils"; +import { + inlineEditSizeIds, + inlineEditVariantIds, + type InlineEditSize, + type InlineEditVariant, +} from "./inline-edit-variants"; + +const inlineEditDisplayVariants = cva( + "group/inline-edit inline-flex max-w-full items-center gap-1.5 rounded-md text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-60 data-[empty=true]:text-muted-foreground", + { + variants: { + variant: { + default: + "border border-transparent hover:border-input data-[interactive=true]:hover:bg-accent/40", + ghost: + "border border-transparent data-[interactive=true]:hover:bg-accent/60", + underline: + "rounded-none border-b border-dashed border-transparent data-[interactive=true]:hover:border-input data-[interactive=true]:hover:text-foreground", + } satisfies Record, + size: { + sm: "min-h-7 px-1.5 py-0.5 text-xs", + default: "min-h-9 px-2 py-1 text-sm", + lg: "min-h-11 px-2.5 py-1.5 text-base", + } satisfies Record, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +const inlineEditInputVariants = cva( + "w-full bg-transparent text-foreground placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60", + { + variants: { + size: { + sm: "text-xs", + default: "text-sm", + lg: "text-base", + } satisfies Record, + }, + defaultVariants: { + size: "default", + }, + }, +); + +const inlineEditContainerVariants = cva( + "inline-flex max-w-full items-stretch gap-1 rounded-md bg-background ring-2 ring-ring ring-offset-2 ring-offset-background", + { + variants: { + size: { + sm: "min-h-7 px-1.5 py-0.5", + default: "min-h-9 px-2 py-1", + lg: "min-h-11 px-2.5 py-1.5", + } satisfies Record, + }, + defaultVariants: { + size: "default", + }, + }, +); + +const inlineEditActionButtonVariants = cva( + "inline-flex shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-[role=confirm]:text-emerald-600 data-[role=confirm]:hover:bg-emerald-500/10 data-[role=confirm]:hover:text-emerald-600 data-[role=cancel]:hover:bg-destructive/10 data-[role=cancel]:hover:text-destructive", + { + variants: { + size: { + sm: "size-5 [&>svg]:size-3", + default: "size-6 [&>svg]:size-3.5", + lg: "size-7 [&>svg]:size-4", + } satisfies Record, + }, + defaultVariants: { + size: "default", + }, + }, +); + +export type InlineEditCommitTrigger = "enter" | "blur" | "button"; + +export interface InlineEditProps + extends Omit< + HTMLAttributes, + "onChange" | "defaultValue" | "onBlur" + >, + VariantProps { + /** The current value to display. */ + value: string; + /** Called when the user commits a new value (Enter, button, or blur). */ + onValueChange?: (next: string, trigger: InlineEditCommitTrigger) => void; + /** Called when the user cancels editing (Escape or cancel button). */ + onCancel?: (previous: string) => void; + /** Optional async save handler. When it rejects, the input stays in edit mode. */ + onSave?: (next: string) => Promise | void; + /** Placeholder shown when the value is empty. */ + placeholder?: string; + /** Disables interaction; the value still renders. */ + disabled?: boolean; + /** Use a multi-line textarea instead of a single-line input. */ + multiline?: boolean; + /** Show explicit confirm/cancel buttons inside the editor. Defaults to true. */ + showActions?: boolean; + /** Show a small pencil icon affordance next to the display value. Defaults to true. */ + showEditIcon?: boolean; + /** Commit the value when the input loses focus. Defaults to true. */ + commitOnBlur?: boolean; + /** Maximum length for the underlying input. */ + maxLength?: number; + /** Accessible label for the underlying input. */ + inputAriaLabel?: string; + /** Accessible label for the confirm button. */ + confirmLabel?: string; + /** Accessible label for the cancel button. */ + cancelLabel?: string; + /** Externally controlled error message. Skips committing and keeps edit mode open. */ + error?: string | null; + /** Externally controlled loading state (e.g. when `onSave` is in flight). */ + loading?: boolean; + /** Renders the editor open on first mount. */ + defaultEditing?: boolean; + /** Optional ref forwarded to the outer wrapper. */ + ref?: Ref; + /** Optional class names applied to the inner input/textarea element. */ + inputClassName?: string; + /** Optional renderer for the display state. Receives the resolved label. */ + renderDisplay?: (label: ReactNode, value: string) => ReactNode; +} + +function isTextarea( + el: HTMLInputElement | HTMLTextAreaElement | null, +): el is HTMLTextAreaElement { + return el?.tagName === "TEXTAREA"; +} + +export function InlineEdit({ + value, + onValueChange, + onCancel, + onSave, + placeholder = "Click to edit", + disabled = false, + multiline = false, + showActions = true, + showEditIcon = true, + commitOnBlur = true, + maxLength, + inputAriaLabel, + confirmLabel = "Save", + cancelLabel = "Cancel", + error: externalError = null, + loading: externalLoading = false, + defaultEditing = false, + variant, + size, + className, + inputClassName, + renderDisplay, + ref, + onClick, + ...rest +}: InlineEditProps): ReactElement { + const [editing, setEditing] = useState(defaultEditing); + const [draft, setDraft] = useState(value); + const [internalLoading, setInternalLoading] = useState(false); + const [internalError, setInternalError] = useState(null); + const wasJustOpened = useRef(false); + const inputRef = useRef(null); + const reactId = useId(); + const errorId = `${reactId}-error`; + + const isLoading = externalLoading || internalLoading; + const errorMessage = externalError ?? internalError; + const hasError = errorMessage !== null && errorMessage !== ""; + + useEffect((): void => { + if (!editing) setDraft(value); + }, [value, editing]); + + useLayoutEffect((): void => { + if (!editing) return; + const el = inputRef.current; + if (el === null) return; + if (wasJustOpened.current) { + wasJustOpened.current = false; + el.focus(); + try { + const length = el.value.length; + el.setSelectionRange(length, length); + } catch { + // setSelectionRange is unsupported on some input types; ignore. + } + } + if (isTextarea(el)) { + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + } + }, [editing, draft]); + + const openEditor = useCallback((): void => { + if (disabled) return; + wasJustOpened.current = true; + setDraft(value); + setInternalError(null); + setEditing(true); + }, [disabled, value]); + + const closeEditor = useCallback((): void => { + setEditing(false); + setInternalError(null); + }, []); + + const commit = useCallback( + async (trigger: InlineEditCommitTrigger): Promise => { + if (isLoading) return; + const next = draft; + if (next === value) { + closeEditor(); + return; + } + if (onSave === undefined) { + onValueChange?.(next, trigger); + closeEditor(); + return; + } + setInternalLoading(true); + try { + await onSave(next); + onValueChange?.(next, trigger); + closeEditor(); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to save value"; + setInternalError(message); + } finally { + setInternalLoading(false); + } + }, + [closeEditor, draft, isLoading, onSave, onValueChange, value], + ); + + const cancel = useCallback((): void => { + if (isLoading) return; + setDraft(value); + onCancel?.(value); + closeEditor(); + }, [closeEditor, isLoading, onCancel, value]); + + const handleChange = ( + event: ChangeEvent, + ): void => { + setDraft(event.target.value); + if (internalError !== null) setInternalError(null); + }; + + const handleKeyDown = ( + event: KeyboardEvent, + ): void => { + if (event.key === "Escape") { + event.preventDefault(); + cancel(); + return; + } + if (event.key === "Enter") { + if (multiline && !(event.metaKey || event.ctrlKey)) return; + event.preventDefault(); + void commit("enter"); + } + }; + + const handleBlur = ( + event: FocusEvent, + ): void => { + if (!commitOnBlur) return; + const next = event.relatedTarget as Node | null; + const root = event.currentTarget.closest("[data-slot=inline-edit-editor]"); + if (next !== null && root !== null && root.contains(next)) return; + void commit("blur"); + }; + + const handleDisplayKeyDown = ( + event: KeyboardEvent, + ): void => { + if (disabled) return; + if (event.key === "Enter" || event.key === " " || event.key === "F2") { + event.preventDefault(); + openEditor(); + } + }; + + const isEmpty = value.length === 0; + const displayLabel: ReactNode = isEmpty ? placeholder : value; + + if (editing) { + return ( +
+
+ {multiline ? ( +