Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/apollo-wind/apollo-ai-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
206 changes: 206 additions & 0 deletions packages/apollo-wind/src/components/ui/editable-text.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof EditableText> = {
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<typeof meta>;

/** Owns the text, as any consumer of a controlled field must. */
function Live({
initial,
...props
}: { initial: string } & Omit<EditableTextProps, 'value' | 'onChange'>) {
const [value, setValue] = useState(initial);
return <EditableText onSubmit={() => {}} {...props} value={value} onChange={setValue} />;
}

const Case = ({
label,
hint,
children,
}: {
label: string;
hint: string;
children: React.ReactNode;
}) => (
<div className="flex flex-col gap-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">
{label}
</div>
<div className="text-xs text-foreground-subtle">{hint}</div>
<div className="rounded-lg border border-border-subtle bg-surface px-4 py-3">{children}</div>
</div>
);

const Frame = ({ children }: { children: React.ReactNode }) => (
<div className="flex w-[420px] flex-col gap-6 p-6">{children}</div>
);

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) => (
<Frame>
<Live {...args} initial={LONG_TEXT} />
</Frame>
),
};

export const MultilineModes: Story = {
name: 'Multiline settings',
render: () => (
<Frame>
<Case
label="Single line (default)"
hint="Renders an input. The collapsed text truncates with an ellipsis, and Enter commits."
>
<Live initial={LONG_TEXT} aria-label="Single line name" />
</Case>

<Case
label="multiline"
hint="Renders a textarea. Shift+Enter inserts a newline, a plain Enter commits."
>
<Live
initial={'Validate the payload\nthen branch on the result'}
multiline
aria-label="Multiline description"
/>
</Case>

<Case
label='multiline="wrap"'
hint="Same wrapping layout, single-line value. Every Enter commits, including Shift+Enter, and pasted newlines collapse to spaces."
>
<Live initial={LONG_TEXT} multiline="wrap" aria-label="Wrapped name" />
</Case>

<Case
label="maxLines"
hint="Caps the visible lines in both modes. The collapsed text clamps, the editor scrolls past the cap."
>
<Live
initial={`${LONG_TEXT}. ${LONG_TEXT}`}
multiline
maxLines={2}
size="sm"
aria-label="Capped description"
/>
</Case>
</Frame>
),
};

export const Sizes: Story = {
name: 'Sizes',
render: () => (
<Frame>
<Case label='size="label"' hint="A form field label, as in a form builder's field header.">
<Live initial="Invoice total" size="label" aria-label="Field label" />
</Case>

<Case label='size="sm"' hint="A secondary line, such as a description.">
<Live initial="Runs after the approval step" size="sm" aria-label="Description" />
</Case>

<Case label='size="lg"' hint="A title. The default.">
<Live initial="Enrich customer record" aria-label="Title" />
</Case>
</Frame>
),
};

export const DisabledStates: Story = {
name: 'Disabled settings',
render: () => (
<Frame>
<Case label="Enabled" hint="Click the text to edit it.">
<Live initial="Enrich customer record" aria-label="Enabled name" />
</Case>

<Case
label="disabled"
hint="Dimmed, with a not-allowed cursor. Clicks open no editor and the value cannot change."
>
<Live initial="Enrich customer record" disabled aria-label="Disabled name" />
</Case>

<Case label="disabled, small" hint="The same lock on the secondary description line.">
<Live
initial="Runs after the approval step"
size="sm"
disabled
aria-label="Disabled description"
/>
</Case>

<Case
label="disabled with an error"
hint="A rejected value stays visible and explained while the field is locked."
>
<Live
initial=""
placeholder="Untitled"
disabled
error="A name is required."
aria-label="Disabled invalid name"
/>
</Case>

<Case
label="Static text (no callbacks)"
hint="Not the same as disabled. There is no trigger at all, so nothing is dimmed."
>
<EditableText value="Read-only label" />
</Case>
</Frame>
),
};
Loading
Loading