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 ? ( +