diff --git a/packages/apollo-wind/src/components/ui/file-upload.tsx b/packages/apollo-wind/src/components/ui/file-upload.tsx
index d3c1682b6..86f93007c 100644
--- a/packages/apollo-wind/src/components/ui/file-upload.tsx
+++ b/packages/apollo-wind/src/components/ui/file-upload.tsx
@@ -6,6 +6,13 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib';
export interface FileUploadProps {
+ /**
+ * Overrides the default "File upload area" / "File upload" accessible names.
+ * The dropzone is a `div[role=group]`, which isn't a labelable element, so a
+ * surrounding `
` can't associate with it -- pass the field's
+ * visible label text here instead.
+ */
+ ariaLabel?: string;
onFilesChange?: (files: File[]) => void;
accept?: string;
multiple?: boolean;
@@ -18,6 +25,7 @@ export interface FileUploadProps {
}
export function FileUpload({
+ ariaLabel,
onFilesChange,
accept,
multiple = false,
@@ -215,7 +223,7 @@ export function FileUpload({
{/** biome-ignore lint/a11y/useSemanticElements: Intended */}
diff --git a/packages/apollo-wind/src/components/ui/lockable-value-field/components/field-header.tsx b/packages/apollo-wind/src/components/ui/lockable-value-field/components/field-header.tsx
new file mode 100644
index 000000000..b832a1cbf
--- /dev/null
+++ b/packages/apollo-wind/src/components/ui/lockable-value-field/components/field-header.tsx
@@ -0,0 +1,259 @@
+import { Asterisk, Braces, ChevronDown, Sparkles } from 'lucide-react';
+import type { ReactNode } from 'react';
+import { useId, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Label } from '@/components/ui/label';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Switch } from '@/components/ui/switch';
+import { Textarea } from '@/components/ui/textarea';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import { cn } from '@/lib';
+import type { LockableFieldType, LockableValueFieldOption } from '../types';
+import { FIELD_TYPE_META, FIELD_TYPE_ORDER } from '../types';
+
+export function FieldHeader({
+ label,
+ fieldId,
+ fieldLabel,
+ required,
+ fieldType,
+ onFieldTypeChange,
+ onRequiredChange,
+ controlsVisibility,
+ compact,
+ showFieldActions,
+ value,
+ onValueChange,
+ variables,
+ onGenerateWithAi,
+ headerActions,
+}: {
+ label?: ReactNode;
+ fieldId: string;
+ fieldLabel: string;
+ required?: boolean;
+ fieldType: LockableFieldType;
+ onFieldTypeChange?: (fieldType: LockableFieldType) => void;
+ onRequiredChange?: (required: boolean) => void;
+ controlsVisibility: 'visible' | 'hover';
+ compact?: boolean;
+ showFieldActions: boolean;
+ value: string;
+ onValueChange?: (value: string) => void;
+ variables: LockableValueFieldOption[];
+ onGenerateWithAi?: (prompt: string) => void;
+ headerActions?: ReactNode;
+}) {
+ const promptId = useId();
+ const [aiPrompt, setAiPrompt] = useState('');
+ const typeMeta = FIELD_TYPE_META[fieldType];
+ // 259px is the container width below which these controls no longer fit
+ // alongside their text labels, so they collapse to icon-only.
+ const collapsedTextClass = cn('@max-[259px]:hidden', compact && '!hidden');
+ const collapsedPaddingClass = cn('@max-[259px]:px-1.5', compact && '!px-1.5');
+ const compactOnlyClass = cn('hidden @max-[259px]:block', compact && '!block');
+
+ return (
+
+ {label ?? (
+
+ {fieldLabel}
+ {required && * }
+
+ )}
+
+
+
+ {onFieldTypeChange && (
+
+
+
+
+
+
+ {typeMeta.label}
+
+
+
+
+ Type
+
+
+ {FIELD_TYPE_ORDER.map((type) => {
+ const meta = FIELD_TYPE_META[type];
+ const isActive = type === fieldType;
+ return (
+ onFieldTypeChange(type)}>
+
+
+ {meta.label}
+
+
+ );
+ })}
+
+
+ )}
+ {onRequiredChange && (
+ <>
+
+
+
+ {/* Wrapped in a span: TooltipTrigger's asChild merge otherwise
+ overwrites the Switch's own data-state (checked/unchecked)
+ with the tooltip's open/closed state, breaking its color classes. */}
+
+
+
+
+ Required
+
+
+
+
+
+
+
+
+
+
+
+
+ Required
+
+
+
+ Required
+
+
+
+
+
+ >
+ )}
+ {showFieldActions && (
+ <>
+
+
+
+
+
+
+
+
+
+ Generate with AI
+
+
+
+
+ Describe what you want
+
+
+
+ Output: {typeMeta.label}
+ {typeMeta.supportsExpression ? ' expression' : ' value'}
+
+ onGenerateWithAi?.(aiPrompt)}
+ >
+ Generate
+
+
+
+
+
+
+
+
+
+ Insert
+
+
+
+
+ Insert variable
+
+
+ {variables.map((variable) => (
+
+ onValueChange?.(value ? `${value} ${variable.value}` : variable.value)
+ }
+ >
+ {variable.label}
+
+ ))}
+
+
+ >
+ )}
+
+ {headerActions}
+
+
+
+ );
+}
diff --git a/packages/apollo-wind/src/components/ui/lockable-value-field/components/lock-toggle-button.tsx b/packages/apollo-wind/src/components/ui/lockable-value-field/components/lock-toggle-button.tsx
new file mode 100644
index 000000000..682ac9815
--- /dev/null
+++ b/packages/apollo-wind/src/components/ui/lockable-value-field/components/lock-toggle-button.tsx
@@ -0,0 +1,44 @@
+import { LockKeyhole, LockKeyholeOpen } from 'lucide-react';
+import { InputGroupButton } from '@/components/ui/input-group';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+
+/**
+ * The lock/unlock toggle shared by both the InputGroup and plain-Input
+ * layouts. Disabled (and its label adjusted) when onLockedChange isn't
+ * provided, since clicking it wouldn't do anything otherwise.
+ */
+export function LockToggleButton({
+ locked,
+ onLockedChange,
+}: {
+ locked: boolean;
+ onLockedChange?: (locked: boolean) => void;
+}) {
+ const interactive = !!onLockedChange;
+ return (
+
+
+
+ onLockedChange?.(!locked)}
+ aria-label={
+ interactive
+ ? locked
+ ? 'Read-only. Click to make editable.'
+ : 'Editable. Click to make read-only.'
+ : locked
+ ? 'Read-only'
+ : 'Editable'
+ }
+ >
+ {locked ? : }
+
+
+ {locked ? 'Read-only' : 'Editable'}
+
+
+ );
+}
diff --git a/packages/apollo-wind/src/components/ui/lockable-value-field/components/mode-menu-item.tsx b/packages/apollo-wind/src/components/ui/lockable-value-field/components/mode-menu-item.tsx
new file mode 100644
index 000000000..21bc1707b
--- /dev/null
+++ b/packages/apollo-wind/src/components/ui/lockable-value-field/components/mode-menu-item.tsx
@@ -0,0 +1,30 @@
+import type { LucideIcon } from 'lucide-react';
+import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
+import { cn } from '@/lib';
+
+/** One entry in the Fixed/Expression value-type dropdown. */
+export function ModeMenuItem({
+ icon: Icon,
+ label,
+ description,
+ active,
+ onClick,
+}: {
+ icon: LucideIcon;
+ label: string;
+ description: string;
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+
+ {label}
+
+
+ {description}
+
+ );
+}
diff --git a/packages/apollo-wind/src/components/ui/lockable-value-field/index.ts b/packages/apollo-wind/src/components/ui/lockable-value-field/index.ts
new file mode 100644
index 000000000..7991efcb8
--- /dev/null
+++ b/packages/apollo-wind/src/components/ui/lockable-value-field/index.ts
@@ -0,0 +1,8 @@
+export { LockableValueField } from './lockable-value-field';
+export type {
+ LockableFieldType,
+ LockableValueFieldMode,
+ LockableValueFieldOption,
+ LockableValueFieldProps,
+} from './types';
+export { FIELD_TYPE_META, FIELD_TYPE_ORDER } from './types';
diff --git a/packages/apollo-wind/src/components/ui/lockable-value-field/lockable-value-field.stories.tsx b/packages/apollo-wind/src/components/ui/lockable-value-field/lockable-value-field.stories.tsx
new file mode 100644
index 000000000..7de51637a
--- /dev/null
+++ b/packages/apollo-wind/src/components/ui/lockable-value-field/lockable-value-field.stories.tsx
@@ -0,0 +1,326 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { X } from 'lucide-react';
+import { useId, useRef, useState } from 'react';
+import { cn } from '@/lib';
+import { Label } from '../label';
+import { ToggleGroup, ToggleGroupItem } from '../toggle-group';
+import { LockableValueField } from './lockable-value-field';
+import { FIELD_TYPE_META, type LockableFieldType, type LockableValueFieldMode } from './types';
+
+const meta = {
+ title: 'Components/Core/Lockable Value Field',
+ component: LockableValueField,
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component: `
+A field that can be locked to read-only, typed as one of several data types,
+and (for scalar types) switched between a literal value and a JS expression.
+
+- Left lock icon toggles Editable / Read-only. Read-only fields show plain
+ text, not a disabled control.
+- Right value-mode icon switches between Fixed value and Expression,
+ updating the value styling. Only shown for types an expression can
+ produce.
+- Field type dropdown swaps the control itself: String, Integer, Date,
+ Boolean, Single select, Multi select, and File each render their own
+ matching input.
+- Required switch toggles the red asterisk on the label. Only shown when
+ \`onRequiredChange\` is provided.
+- Built-in AI-assist popover to describe and generate a value, and an
+ Insert-variable affordance for binding to upstream data. Both hidden via
+ \`showFieldActions={false}\` for read-only reviewer contexts.
+- \`label\` accepts any ReactNode, so a consumer can compose its own
+ inline-editable title in place of the default text -- see **Controls**
+ below.
+- \`controlsVisibility\` decides whether the type/required/AI/insert/header
+ actions are always shown or only on hover -- see **Controls** below.
+- Header row is responsive (container query): the type, required,
+ AI-assist, and insert-variable controls collapse to icon-only once the
+ field gets too narrow for their labels. See **Responsive** below.
+- Built on \`InputGroup\` for the scalar types that support expressions; see
+ Input Group's \`LockedFieldWithPopover\` story for a lighter-weight recipe
+ using only \`InputGroup\` primitives.
+ `,
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+function CloseFieldButton({ controlsVisibility }: { controlsVisibility: 'visible' | 'hover' }) {
+ return (
+
+
+
+ );
+}
+
+function DefaultDemo() {
+ const fieldId = useId();
+ const [value, setValue] = useState('');
+ const [locked, setLocked] = useState(true);
+ const [mode, setMode] = useState('fixed');
+ const [fieldType, setFieldType] = useState('string');
+ const [required, setRequired] = useState(true);
+
+ const handleFieldTypeChange = (type: LockableFieldType) => {
+ setFieldType(type);
+ setValue('');
+ if (!FIELD_TYPE_META[type].supportsExpression) {
+ setMode('fixed');
+ }
+ };
+
+ return (
+
+
+ Label
+ {required && * }
+
+ }
+ headerActions={ }
+ value={value}
+ onValueChange={setValue}
+ locked={locked}
+ onLockedChange={setLocked}
+ mode={mode}
+ onModeChange={setMode}
+ fieldType={fieldType}
+ onFieldTypeChange={handleFieldTypeChange}
+ required={required}
+ onRequiredChange={setRequired}
+ />
+
+ );
+}
+
+export const Default: Story = {
+ render: () => ,
+};
+
+/**
+ * Matches the inline-editable title pattern the Form HITL quick-form
+ * builder passes into `label`: click the text to edit it, blur or press
+ * Enter/Escape to commit.
+ */
+function InlineEditableLabel({
+ title,
+ onTitleChange,
+ required,
+}: {
+ title: string;
+ onTitleChange: (title: string) => void;
+ required: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const inputRef = useRef(null);
+
+ return editing ? (
+ onTitleChange(e.target.value)}
+ onBlur={() => setEditing(false)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === 'Escape') setEditing(false);
+ }}
+ className="min-w-0 flex-1 rounded bg-surface-overlay px-1 py-0.5 text-xs font-medium text-foreground outline-none ring-1 ring-brand"
+ // biome-ignore lint/a11y/noAutofocus: only mounts in response to the user's own click on the label, not on page load
+ autoFocus
+ />
+ ) : (
+ {
+ setEditing(true);
+ setTimeout(() => inputRef.current?.select(), 0);
+ }}
+ className="truncate rounded px-1 py-0.5 text-left text-xs font-medium text-foreground-muted transition hover:bg-surface-overlay hover:text-foreground"
+ >
+ {title}
+ {required && * }
+
+ );
+}
+
+function ControlsPlayground() {
+ const [title, setTitle] = useState('Label');
+ const [value, setValue] = useState('');
+ const [locked, setLocked] = useState(true);
+ const [mode, setMode] = useState('fixed');
+ const [fieldType, setFieldType] = useState('string');
+ const [required, setRequired] = useState(true);
+ const [controlsVisibility, setControlsVisibility] = useState<'visible' | 'hover'>('visible');
+
+ const handleFieldTypeChange = (type: LockableFieldType) => {
+ setFieldType(type);
+ setValue('');
+ if (!FIELD_TYPE_META[type].supportsExpression) {
+ setMode('fixed');
+ }
+ };
+
+ return (
+
+
+
+ Controls
+
+ v && setControlsVisibility(v as 'visible' | 'hover')}
+ >
+
+ Show
+
+
+ Hide
+
+
+
+
+
+
+ }
+ headerActions={ }
+ value={value}
+ onValueChange={setValue}
+ locked={locked}
+ onLockedChange={setLocked}
+ mode={mode}
+ onModeChange={setMode}
+ fieldType={fieldType}
+ onFieldTypeChange={handleFieldTypeChange}
+ required={required}
+ onRequiredChange={setRequired}
+ controlsVisibility={controlsVisibility}
+ />
+