All components smmdlg
Editable
Inline text that swaps between a hover preview and a focused input on click.
Q3 launch plan
1234567891011121314import { Editable } from "#components/ui/base-ui/editable.tsx";
export function Example() { return ( // Base UI has no editable machine, so Root owns the value/editing state // (value/defaultValue/onValueChange); parts compose through render <Editable.Root defaultValue="Q3 launch plan" size="md"> <Editable.Area> <Editable.Input aria-label="Edit title" /> <Editable.Preview /> </Editable.Area> </Editable.Root> );}Sizes
Q3 launch plan
Q3 launch plan
Q3 launch plan
Install
1npx stylexblocks add editableSource
components/ui/base-ui/editable.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295import { createContext, useContext, useMemo, useState } from "react";import { mergeProps } from "@base-ui-components/react/merge-props";import { useRender } from "@base-ui-components/react/use-render";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, type SlotRecipeVariants } from "#lib/recipe-props.ts";import * as stylex from "@stylexjs/stylex";import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { durations } from '@stylexblocks/ui/tokens/durations.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const editableRoot = stylex.create({ base: { display: 'inline-flex', alignItems: 'center', position: 'relative', gap: spacing._1_5, width: sizes.full, },})
const editableRootSizes = stylex.create({ sm: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, md: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, lg: { fontSize: fontSizes.md, lineHeight: lineHeights.md, },})
const editablePreview = stylex.create({ base: { fontSize: 'inherit', fontWeight: 'inherit', textAlign: 'inherit', backgroundColor: { default: 'transparent', ':hover': semanticColors.bgMuted, }, borderRadius: semanticRadii.control, paddingBlock: spacing._1, paddingInline: spacing._1, display: 'inline-flex', alignItems: 'center', transitionProperty: 'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform', transitionDuration: durations.moderate, cursor: 'text', userSelect: { default: null, ':is(:disabled, [disabled], [data-disabled])': 'none', }, },})
const editablePreviewSizes = stylex.create({ sm: { minHeight: sizes._8, }, md: { minHeight: sizes._9, }, lg: { minHeight: sizes._10, },})
const editableInput = stylex.create({ base: { fontSize: 'inherit', fontWeight: 'inherit', textAlign: 'inherit', backgroundColor: 'transparent', borderRadius: semanticRadii.control, outline: 'none', paddingBlock: spacing._1, paddingInline: spacing._1, transitionProperty: 'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform', transitionDuration: durations.moderate, width: sizes.full, outlineWidth: { default: null, ':is(:focus-visible, [data-focus])': 'var(--focus-ring-width, 1px)', }, outlineOffset: { default: null, ':is(:focus-visible, [data-focus])': '0px', }, outlineStyle: { default: null, ':is(:focus-visible, [data-focus])': 'solid', }, outlineColor: { default: null, ':is(:focus-visible, [data-focus])': colorPalette.focusRing, }, '::placeholder': { opacity: 0.6, }, '--focus-color': colorPalette.focusRing, '--error-color': semanticColors.borderError, '--focus-ring-color': { default: null, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, borderColor: { default: null, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, },})
const editableInputSizes = stylex.create({ sm: { minHeight: sizes._8, }, md: { minHeight: sizes._9, }, lg: { minHeight: sizes._10, },})
const editableControl = stylex.create({ base: { display: 'inline-flex', alignItems: 'center', gap: spacing._1_5, },})
export type EditableSize = keyof typeof editableRootSizes
const editableRecipe = defineSlotRecipe({ slots: { root: { styles: editableRoot, sizes: editableRootSizes }, preview: { styles: editablePreview, sizes: editablePreviewSizes }, input: { styles: editableInput, sizes: editableInputSizes }, control: { styles: editableControl }, }, defaultVariants: { size: "md" },});
interface EditableContextValue { value: string; setValue: (value: string) => void; editing: boolean; setEditing: (editing: boolean) => void;}
const EditableContext = createContext<EditableContextValue>({ value: "", setValue: () => {}, editing: false, setEditing: () => {},});
const { withProvider, withContext } = createSlotRecipeContext(editableRecipe);
export type EditableVariantProps = Partial<SlotRecipeVariants<typeof editableRecipe>>;
function RootNode({ render, value, defaultValue = "", onValueChange, ...rest}: useRender.ComponentProps<"div"> & { value?: string; defaultValue?: string; onValueChange?: (value: string) => void;}) { const [inner, setInner] = useState(defaultValue); const [editing, setEditing] = useState(false); const current = value !== undefined ? value : inner; const ctx = useMemo<EditableContextValue>( () => ({ value: current, setValue: (next: string) => { setInner(next); onValueChange?.(next); }, editing, setEditing, }), [current, editing, onValueChange], ); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: rest, }); return <EditableContext.Provider value={ctx}>{element}</EditableContext.Provider>;}
function InputNode({ render, onChange, onBlur, onKeyDown, ...rest}: Omit<useRender.ComponentProps<"input">, "value">) { const ctx = useContext(EditableContext); const element = useRender({ defaultTagName: "input", render: render as useRender.RenderProp, props: mergeProps( { autoFocus: true, value: ctx.value, onChange: (event: React.ChangeEvent<HTMLInputElement>) => { (onChange as ((event: React.ChangeEvent<HTMLInputElement>) => void) | undefined)?.(event); ctx.setValue(event.target.value); }, onBlur: (event: React.FocusEvent<HTMLInputElement>) => { (onBlur as ((event: React.FocusEvent<HTMLInputElement>) => void) | undefined)?.(event); ctx.setEditing(false); }, onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => { (onKeyDown as ((event: React.KeyboardEvent<HTMLInputElement>) => void) | undefined)?.( event, ); if (event.key === "Enter" || event.key === "Escape") ctx.setEditing(false); }, }, rest, ), }); return ctx.editing ? element : null;}
function PreviewNode({ render, children, onClick, onKeyDown, ...rest}: useRender.ComponentProps<"span">) { const ctx = useContext(EditableContext); const element = useRender({ defaultTagName: "span", render: render as useRender.RenderProp, props: mergeProps( { tabIndex: 0, role: "button", "aria-label": `Edit ${ctx.value}`, onClick: (event: React.MouseEvent<HTMLSpanElement>) => { (onClick as ((event: React.MouseEvent<HTMLSpanElement>) => void) | undefined)?.(event); ctx.setEditing(true); }, onKeyDown: (event: React.KeyboardEvent<HTMLSpanElement>) => { (onKeyDown as ((event: React.KeyboardEvent<HTMLSpanElement>) => void) | undefined)?.( event, ); if (event.key === "Enter") ctx.setEditing(true); }, children: children ?? ctx.value, }, rest, ), }); return ctx.editing ? null : element;}
/** * Editable — inline text that swaps between a hover preview and a focused * input on click. Base UI ships no editable machine, so Root owns the * value/editing state (controlled via `value`/`onValueChange` or * uncontrolled via `defaultValue`). `<Editable.Root size>` resolves the * recipe once; Preview and Input inherit it through context. Composition * per part via `render`. */export const Editable = { Root: withProvider(RootNode, "root", "EditableRoot"), Area: withContext("div", () => [], "EditableArea"), Input: withContext(InputNode, "input", "EditableInput"), Preview: withContext(PreviewNode, "preview", "EditablePreview"), Control: withContext("div", "control", "EditableControl"),};
export type EditableRootProps = React.ComponentProps<typeof Editable.Root>;