diff --git a/packages/apollo-wind/apollo-ai-context.md b/packages/apollo-wind/apollo-ai-context.md
index 6808663df6..f4612ece74 100644
--- a/packages/apollo-wind/apollo-ai-context.md
+++ b/packages/apollo-wind/apollo-ai-context.md
@@ -315,6 +315,7 @@ Put the control inside `InputGroup`. Apollo's controls detect the group and beco
- A custom control that also renders standalone can read `useInputGroup()` (`inGroup`, `layout`, `anchor`).
- `FieldMenu` is the trailing menu: the field's modes, then its actions (`actions`), or an overflow menu alone (`modesDisabled`). Built-in modes are listed by id (`modes={['literal', 'expression', 'variable', 'prompt']}`; default `['literal', 'expression']`); `builtInValueModes(strings)` returns their options. `ValueModeIndicator` shows `=` in expression mode; inside MetadataForm a mode definition's `indicator` marks any other mode.
- `InsertVariableAction` renders `variables` (`VariablePickerItem[]`) as supplied, with no root of its own; pass a function to resolve them when the picker opens. Its strings include the picker's search placeholder and empty state. `AiAssistAction`'s `onGenerate(prompt)` may return a promise (any thenable): the prompt stays busy until it settles and shows `strings.error` on rejection. `disabled` disables the trigger.
+- Host picker: `VariablePicker`, `InsertVariableAction` and `VariableValueControl` take `renderPicker: RenderVariablePicker` (`({ value?, onSelect(value, item?), close }) => ReactNode`; `onSelect` commits and closes; `variables` is then optional), keeping wind's trigger, popover, focus return and strings. `VariableValueControl` also takes `describeReference(reference) => { label, icon? }` for its chip. The slot is called only while the popover is open.
- Localization: every addon and action takes a `strings` prop merged over its `DEFAULT_*_STRINGS`. There is no provider.
#### Tabs
@@ -399,8 +400,8 @@ Exports (all from `@/components/forms`): MetadataForm, ModeAwareField, FormField
FieldControl, FIELD_CONTROL_GEOMETRY, FormDesigner, FormStateViewer, StringListField, RulesEngine,
RuleBuilder, ExpressionBuilder, DataFetcher, DataSourceBuilder, the built-in plugins
(analyticsPlugin, autoSavePlugin, workflowPlugin, auditPlugin, validationPlugin, formattingPlugin),
-createInsertVariableAction, createAiAssistAction, createClearAction, envelopeCodec, literalValues,
-VALUE_MODE_OPAQUE, DEFAULT_METADATA_FORM_STRINGS.
+createInsertVariableAction, createAiAssistAction, createClearAction, createVariableModeControl,
+envelopeCodec, literalValues, VALUE_MODE_OPAQUE, DEFAULT_METADATA_FORM_STRINGS.
Field types: text, email, textarea, number, select, multiselect, radio, checkbox, switch, boolean
(True / False radios with an unset state, `boolean | null`), slider (`maxRef` reads its max from
@@ -423,6 +424,9 @@ registered unless replaced), built from `createInsertVariableAction`, `createAiA
`FormPlugin.variables` (Insert variable falls back to them and hides itself in Variable mode; the
Variable control's picker reads them); and
strings for every built-in through `FormPlugin.strings` (`valueModes`, `boolean`, `insertVariable`, `aiAssist`, `clear`, `validation`).
+A host with its own picker passes it as `renderPicker` to `createInsertVariableAction` and to
+`createVariableModeControl` (registered as `FormPlugin.valueModes.definitions.variable.control`, with
+`describeReference` for chip labels).
Keep plugin objects stable (module constants or memoized), pass `variables` as a function when the
list is long or changes, and keep codecs pure. The default codec stores every value as `{ $mode, value }` (no `value`
once cleared) and reads a raw value as literal, so code that reads form values must expect the
diff --git a/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md b/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md
index d73105cc85..c9941ffb86 100644
--- a/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md
+++ b/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md
@@ -138,9 +138,9 @@ Same props as Row.
| `BooleanRadioGroup` | True / False / not set | `value`: boolean\|null, `onValueChange`, `strings` |
| `FieldMenu` | Mode and actions menu | `mode`, `onSelect`, `modes`, `actions`, `modesDisabled`, `strings` |
| `ValueModeIndicator` | `=` before an expression | `mode`, `strings` |
-| `InsertVariableAction` | Header variable picker | `variables`, `onInsert`, `compact`, `strings` |
+| `InsertVariableAction` | Header variable picker | `variables`, `onInsert`, `compact`, `strings`. `renderPicker` renders the host's own picker instead, and `variables` is then optional |
| `AiAssistAction` | Header AI prompt | `onGenerate`, `hint`, `strings` |
-| `VariableValueControl` | Variable-bound control | `value`, `onChange`, `variables`, `strings` |
+| `VariableValueControl` | Variable-bound control | `value`, `onChange`, `variables`, `strings`. `renderPicker` renders the host's own picker; `describeReference(reference)` gives the chip a `{ label, icon? }` |
| `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`, `renderModeControl`, `variables`, `strings` |
| `Label` | Bare label primitive | `htmlFor`. For a field use `FormFieldLabel` |
@@ -926,7 +926,7 @@ an expression, a variable or a prompt, switched from a trailing menu.
| ------------ | ----------- | ----------------------------------------------------------- |
| `literal` | Fixed value | The field type's own control |
| `expression` | Expression | A plain input behind an `=` indicator |
-| `variable` | Variable | `VariableValueControl`, picking from `FormPlugin.variables` |
+| `variable` | Variable | `VariableValueControl`, picking from `FormPlugin.variables`, or the host's control from `createVariableModeControl` |
| `prompt` | Prompt | `PromptValueControl` in an agent-tinted `InputGroup` |
| Key | Description |
@@ -959,6 +959,10 @@ Field actions:
against `FormPlugin.fieldActions`; unregistered ids are skipped.
- Insert variable writes at the caret of a text control, otherwise it switches the field to
`expression` or `variable` mode, asking before it replaces a value.
+- A host with its own picker passes it as `renderPicker` to `createInsertVariableAction` (it wins
+ over `variables`, and its pick takes the same path) and to `createVariableModeControl`, registered
+ as `FormPlugin.valueModes.definitions.variable.control`. That control also takes
+ `describeReference(reference, { field })` for the chip's label and icon.
- `badge` adds text after the label, such as the value's type.
Any of `valueModes`, `headerActions`, `menuActions` or `badge` renders the field through
diff --git a/packages/apollo-wind/src/components/forms/README.md b/packages/apollo-wind/src/components/forms/README.md
index 1c3c13e2dd..08c8929127 100644
--- a/packages/apollo-wind/src/components/forms/README.md
+++ b/packages/apollo-wind/src/components/forms/README.md
@@ -916,7 +916,7 @@ Validation: a fixed value is checked by its field type's `validation`; any other
| Factory | Action |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
-| `createInsertVariableAction({ variables?, formatReference?, strings?, id? })` | Inserts a variable. An `insertable` control takes it at the caret; otherwise the field switches to `expression`, then `variable`, asking before it replaces a value. Hidden in Variable mode. |
+| `createInsertVariableAction({ variables?, renderPicker?, formatReference?, strings?, id? })` | Inserts a variable. An `insertable` control takes it at the caret; otherwise the field switches to `expression`, then `variable`, asking before it replaces a value. Hidden in Variable mode. `renderPicker` renders the host's own picker and wins over `variables`. |
| `createAiAssistAction({ generate, hint?, placeholder?, strings?, id? })` | Asks for a prompt and writes `generate`'s result in the field's mode, or the `mode` it returns. Skips the write if the field became disabled; closing the prompt does not cancel the request. |
| `createClearAction({ strings?, id? })` | Clears the value, keeping the mode. Registered as `clear` by default. |
@@ -926,6 +926,15 @@ Register an action under the same id to replace a built-in.
- `FormPlugin.variables` feeds both Insert variable and the `variable` mode: a list, or a function
called each time a picker opens (never during render). The last plugin that gives them wins.
+- A host with its own picker passes it as `renderPicker` to `createInsertVariableAction` and to
+ `createVariableModeControl`, whose registration goes in
+ `FormPlugin.valueModes.definitions.variable.control`. The picker, a `FormVariablePicker`, is
+ called only while a picker is open, with `{ field, value, onSelect, close }`: `value` is the
+ bound reference in the `variable` mode, and `onSelect(reference, item?)` commits it and closes,
+ so Insert still writes at the caret or asks before replacing. Wind keeps the trigger, popover,
+ focus return and strings.
+- `createVariableModeControl({ describeReference })` gives the `variable` mode's chip the host's
+ `{ label, icon? }` for a bound reference; returning nothing shows the reference as stored.
- `FormPlugin.strings` overrides any built-in string by group (`valueModes`, `boolean`,
`insertVariable`, `aiAssist`, `clear`, `validation`); later plugins win per string. The English
defaults are `DEFAULT_METADATA_FORM_STRINGS`.
@@ -935,6 +944,7 @@ Register an action under the same id to replace a built-in.
- Keep plugin objects stable: a module constant, or memoized. A new plugin object rebuilds the
registries and re-renders every field; a new array of the same plugins is fine.
- Pass `variables` as a function when the list is long or changes.
+- Call `createVariableModeControl` once, at module scope: each call makes a new control component.
- Keep codecs pure: no live values captured in a codec.
## 📊 Data Sources
diff --git a/packages/apollo-wind/src/components/forms/field-actions.stories.tsx b/packages/apollo-wind/src/components/forms/field-actions.stories.tsx
index 18b44fb4c9..baf182cba0 100644
--- a/packages/apollo-wind/src/components/forms/field-actions.stories.tsx
+++ b/packages/apollo-wind/src/components/forms/field-actions.stories.tsx
@@ -4,6 +4,7 @@ import {
createAiAssistAction,
createClearAction,
createInsertVariableAction,
+ type FormVariablePicker,
} from './field-actions';
import type { FormPlugin, FormSchema } from './form-schema';
import { MetadataForm } from './metadata-form';
@@ -23,6 +24,10 @@ per-field placeholder) and \`createClearAction\`, which is registered by default
the caret of a control that takes text; otherwise it switches the field to an expression, asking
first before it replaces a value other than a number or boolean. Their strings come from
\`FormPlugin.strings\`, and a factory's own \`strings\` win.
+
+A host can render its own panels in these popovers.
+\`createInsertVariableAction({ renderPicker })\` replaces the picker and wins over \`variables\`; a
+pick is written as a picked variable would be.
`,
},
},
@@ -146,3 +151,41 @@ const plugin: FormPlugin = {
export const Actions: Story = {
render: () => ,
};
+
+// Stand-in for a host's own picker, such as a virtualized tree with an Assets tab.
+const hostPicker: FormVariablePicker = ({ onSelect, close }) => (
+
+ Host picker
+ {['$vars.orderId', '$assets.apiKey'].map((reference) => (
+ onSelect(reference)}
+ className="rounded px-2 py-1 text-left font-mono hover:bg-surface-overlay"
+ >
+ {reference}
+
+ ))}
+
+ Cancel
+
+
+);
+
+const hostPlugin: FormPlugin = {
+ name: 'host',
+ fieldActions: {
+ header: {
+ 'insert-variable': createInsertVariableAction({
+ renderPicker: hostPicker,
+ formatReference: (reference, { mode }) =>
+ mode === 'literal' ? `{{ ${reference} }}` : reference,
+ }),
+ },
+ },
+};
+
+/** Insert variable rendering the host's own picker, through its factory. */
+export const HostPanels: Story = {
+ render: () => ,
+};
diff --git a/packages/apollo-wind/src/components/forms/field-actions.tsx b/packages/apollo-wind/src/components/forms/field-actions.tsx
index fb1ee7cf68..4dbd4f0ad3 100644
--- a/packages/apollo-wind/src/components/forms/field-actions.tsx
+++ b/packages/apollo-wind/src/components/forms/field-actions.tsx
@@ -9,7 +9,11 @@ import {
InsertVariableAction,
type InsertVariableActionStrings,
} from '@/components/ui/field-actions/insert-variable-action';
-import type { VariablePickerItem } from '@/components/ui/variable-picker';
+import type {
+ RenderVariablePicker,
+ VariablePickerItem,
+ VariablePickerSlotProps,
+} from '@/components/ui/variable-picker';
import type { FieldMetadata, FormPlugin, FormVariables, ValueModeId } from './form-schema';
import type { ClearActionStrings, MetadataFormStrings } from './form-strings';
import type { DecodedValue, ValueModeControlHandle } from './value-modes';
@@ -76,6 +80,29 @@ export interface FieldMenuAction {
/** A variable Insert variable offers, rendered as supplied. */
export type ValueModeVariable = VariablePickerItem;
+/**
+ * A host's own variable picker for a form field, rendered in place of the built-in tree. Called
+ * only while the picker is open, with the field it picks for.
+ */
+export type FormVariablePicker = (
+ props: VariablePickerSlotProps & { field: FieldMetadata }
+) => React.ReactNode;
+
+/** Binds `picker` to each field once, so the action's props stay stable across renders. */
+export function bindVariablePicker(
+ picker: FormVariablePicker
+): (field: FieldMetadata) => RenderVariablePicker {
+ const bound = new WeakMap();
+ return (field) => {
+ let render = bound.get(field);
+ if (!render) {
+ render = (props) => picker({ ...props, field });
+ bound.set(field, render);
+ }
+ return render;
+ };
+}
+
/** `source` for `field`: a function is called now, so only call this when a picker opens. */
export function resolveVariables(
source: FormVariables | undefined,
@@ -115,6 +142,11 @@ export interface FieldActionRegistry {
export interface InsertVariableActionOptions {
/** Defaults to the form's `FormPlugin.variables`. */
variables?: FormVariables;
+ /**
+ * The host's own picker, rendered in place of a picker over `variables`, which it wins over. Its
+ * pick is written like a picked variable: at the caret, or as the value once confirmed.
+ */
+ renderPicker?: FormVariablePicker;
/**
* How a picked reference is written at the caret, such as `{{ ref }}` inside fixed-value text.
* A field that switches to `expression` or `variable` to take the reference gets it as picked.
@@ -171,10 +203,12 @@ function insertTarget(
/** Insert variable: a variable picker behind the label that writes the picked reference. */
export function createInsertVariableAction({
variables,
+ renderPicker,
formatReference,
strings,
id = 'insert-variable',
}: InsertVariableActionOptions): FieldHeaderAction {
+ const pickerFor = renderPicker && bindVariablePicker(renderPicker);
return {
id,
render: (ctx) => {
@@ -182,6 +216,19 @@ export function createInsertVariableAction({
if (ctx.mode?.mode === 'variable') return null;
const text = { ...ctx.strings.insertVariable, ...strings };
const insert = insertTarget(ctx, formatReference, text);
+ // Resolved at pick time: the mode or control may have changed since render.
+ const onInsert =
+ insert && ((reference: string) => insertTarget(ctx, formatReference, text)?.(reference));
+ if (pickerFor) {
+ return (
+
+ );
+ }
return (
(variables ? resolveVariables(variables, ctx.field) : ctx.variables())
}
- // Resolved at pick time: the mode or control may have changed since render.
- onInsert={
- insert && ((reference) => insertTarget(ctx, formatReference, text)?.(reference))
- }
+ onInsert={onInsert}
disabled={ctx.disabled}
strings={text}
/>
diff --git a/packages/apollo-wind/src/components/forms/index.ts b/packages/apollo-wind/src/components/forms/index.ts
index d299fbeba9..d324e732df 100644
--- a/packages/apollo-wind/src/components/forms/index.ts
+++ b/packages/apollo-wind/src/components/forms/index.ts
@@ -24,6 +24,7 @@ export {
type FieldActionsPluginConfig,
type FieldHeaderAction,
type FieldMenuAction,
+ type FormVariablePicker,
type InsertVariableActionOptions,
resolveVariables,
type ValueModeVariable,
@@ -112,3 +113,7 @@ export {
type ValueModeRegistry,
type ValueModesPluginConfig,
} from './value-modes';
+export {
+ createVariableModeControl,
+ type VariableModeControlOptions,
+} from './variable-mode-control';
diff --git a/packages/apollo-wind/src/components/forms/mode-aware-field.test.tsx b/packages/apollo-wind/src/components/forms/mode-aware-field.test.tsx
index c805d5cfd7..f4f3a2c1cf 100644
--- a/packages/apollo-wind/src/components/forms/mode-aware-field.test.tsx
+++ b/packages/apollo-wind/src/components/forms/mode-aware-field.test.tsx
@@ -10,6 +10,7 @@ import {
createInsertVariableAction,
type FieldActionContext,
type FieldActionGenerate,
+ type FormVariablePicker,
} from './field-actions';
import type {
CustomFieldComponentProps,
@@ -19,6 +20,10 @@ import type {
} from './form-schema';
import { MetadataForm } from './metadata-form';
import { envelopeCodec, type ValueModeCodec, type ValueModeControlProps } from './value-modes';
+import {
+ createVariableModeControl,
+ type VariableModeControlOptions,
+} from './variable-mode-control';
const MODES = { modes: ['literal', 'expression'] };
@@ -1631,3 +1636,136 @@ describe('built-in variable and prompt controls', () => {
expect(screen.queryByText('Order id')).toBeNull();
});
});
+
+describe('host variable picker', () => {
+ const variables = [{ id: 'order', label: 'Order id', value: '$vars.orderId' }];
+ // A host picker with one row; it records the field and bound value it was handed.
+ const hostPicker = vi.fn(({ onSelect }) => (
+ onSelect('$host.orderId')}>
+ Host order id
+
+ ));
+ const variablesPlugin: FormPlugin = { name: 'variables', variables };
+ afterEach(() => hostPicker.mockClear());
+
+ describe('Insert variable', () => {
+ it('inserts a host pick at the caret, and closes back to the trigger', async () => {
+ const { user, values } = setup(
+ [{ name: 'msg', type: 'text', label: 'Message', headerActions: ['insert-variable'] }],
+ [variablesPlugin, insertPlugin({ renderPicker: hostPicker })],
+ { msg: 'Hi !' }
+ );
+ await settle();
+ expect(hostPicker).not.toHaveBeenCalled();
+ (screen.getByLabelText('Message') as HTMLInputElement).setSelectionRange(3, 3);
+ const trigger = screen.getByRole('button', { name: 'Insert variable' });
+
+ await user.click(trigger);
+ expect(hostPicker).toHaveBeenCalledWith(
+ expect.objectContaining({ field: expect.objectContaining({ name: 'msg' }) })
+ );
+ // The picker wins over the form's variables.
+ expect(screen.queryByText('Order id')).toBeNull();
+ await user.click(await screen.findByRole('button', { name: 'Host order id' }));
+ expect(values().msg).toBe('Hi $host.orderId!');
+ expect(screen.queryByRole('button', { name: 'Host order id' })).toBeNull();
+ });
+
+ it('asks before replacing a chosen option with a host pick', async () => {
+ const { user, values } = setup(
+ [
+ {
+ name: 's',
+ type: 'select',
+ label: 'S',
+ options: [{ label: 'One', value: '1' }],
+ valueModes: MODES,
+ headerActions: ['insert-variable'],
+ },
+ ],
+ [insertPlugin({ renderPicker: hostPicker })],
+ { s: { $mode: 'literal', value: '1' } }
+ );
+ await settle();
+ await user.click(screen.getByRole('button', { name: 'Insert variable' }));
+ await user.click(await screen.findByRole('button', { name: 'Host order id' }));
+ const dialog = await screen.findByRole('alertdialog');
+ expect(values().s).toStrictEqual({ $mode: 'literal', value: '1' });
+ await user.click(within(dialog).getByRole('button', { name: 'Replace' }));
+ expect(values().s).toStrictEqual({ $mode: 'expression', value: '$host.orderId' });
+ });
+
+ it("wins over the action's own variables", async () => {
+ const { user } = setup(
+ [{ name: 'msg', type: 'text', label: 'Message', headerActions: ['insert-variable'] }],
+ [insertPlugin({ variables, renderPicker: hostPicker })]
+ );
+ await settle();
+ await user.click(screen.getByRole('button', { name: 'Insert variable' }));
+ expect(await screen.findByRole('button', { name: 'Host order id' })).toBeInTheDocument();
+ expect(screen.queryByText('Order id')).toBeNull();
+ });
+ });
+
+ describe('variable mode', () => {
+ const field: FieldMetadata = {
+ name: 'v',
+ type: 'text',
+ label: 'V',
+ valueModes: { modes: ['literal', 'variable'] },
+ };
+ const controlPlugin = (options: VariableModeControlOptions): FormPlugin => ({
+ name: 'variable-control',
+ valueModes: { definitions: { variable: { control: createVariableModeControl(options) } } },
+ });
+
+ it('binds a host pick, handing the picker the bound reference', async () => {
+ const { user, values } = setup(
+ [field],
+ [variablesPlugin, controlPlugin({ renderPicker: hostPicker })],
+ { v: { $mode: 'variable', value: '$vars.old' } }
+ );
+ await settle();
+ const trigger = screen.getByRole('button', { name: 'V' });
+ expect(trigger.closest('[data-slot="input-group"]')).toHaveAttribute(
+ 'data-value-mode-control',
+ 'definition'
+ );
+ await user.click(trigger);
+ expect(hostPicker).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: '$vars.old',
+ field: expect.objectContaining({ name: 'v' }),
+ })
+ );
+ await user.click(await screen.findByRole('button', { name: 'Host order id' }));
+ expect(values().v).toStrictEqual({ $mode: 'variable', value: '$host.orderId' });
+ expect(screen.queryByRole('button', { name: 'Host order id' })).toBeNull();
+ expect(trigger).toHaveFocus();
+ });
+
+ it("shows the host's description of the bound reference, picking from the form's variables", async () => {
+ const describeReference = vi.fn>(
+ (reference) => (reference === '$vars.orderId' ? { label: 'Order id' } : undefined)
+ );
+ const { user, values } = setup(
+ [field],
+ [variablesPlugin, controlPlugin({ describeReference })],
+ { v: { $mode: 'variable', value: '$vars.old' } }
+ );
+ await settle();
+ const trigger = screen.getByRole('button', { name: 'V' });
+ expect(trigger).toHaveTextContent('$vars.old');
+ await user.click(trigger);
+ await user.click(await screen.findByText('Order id'));
+ expect(values().v).toStrictEqual({ $mode: 'variable', value: '$vars.orderId' });
+
+ const chip = trigger.querySelector('[data-slot="reference-chip"]');
+ expect(chip).toHaveTextContent('Order id');
+ expect(chip).not.toHaveTextContent('$vars.orderId');
+ expect(describeReference).toHaveBeenCalledWith('$vars.orderId', {
+ field: expect.objectContaining({ name: 'v' }),
+ });
+ });
+ });
+});
diff --git a/packages/apollo-wind/src/components/forms/value-modes.stories.tsx b/packages/apollo-wind/src/components/forms/value-modes.stories.tsx
index 6dc547b559..c625e2a041 100644
--- a/packages/apollo-wind/src/components/forms/value-modes.stories.tsx
+++ b/packages/apollo-wind/src/components/forms/value-modes.stories.tsx
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
-import { Regex } from 'lucide-react';
-import { useMemo, useState } from 'react';
-import { createInsertVariableAction } from './field-actions';
+import { Hash, KeyRound, Regex } from 'lucide-react';
+import { type ReactNode, useMemo, useState } from 'react';
+import { createInsertVariableAction, type FormVariablePicker } from './field-actions';
import type { FormPlugin, FormSchema } from './form-schema';
import { MetadataForm } from './metadata-form';
import {
@@ -10,6 +10,7 @@ import {
type ValueModeCodec,
type ValueModeControlProps,
} from './value-modes';
+import { createVariableModeControl } from './variable-mode-control';
const meta = {
title: 'Forms/Value modes',
@@ -26,7 +27,10 @@ field's fixed value only. Switching a non-empty value asks first; an empty one n
codec's \`convert\` may seed it. Hosts add codecs, modes and controls through
\`FormPlugin.valueModes\` (a definition for a built-in mode changes only what it gives, such as its
control) and strings through \`FormPlugin.strings\`. The form's variables, from
-\`FormPlugin.variables\`, feed the Variable mode's picker and Insert variable.
+\`FormPlugin.variables\`, feed the Variable mode's picker and Insert variable. A host with its own
+picker passes it to \`createInsertVariableAction({ renderPicker })\` and to
+\`createVariableModeControl({ renderPicker, describeReference })\`, registered as the Variable mode's
+control; \`describeReference\` shows the host's name and type icon for a bound reference.
`,
},
},
@@ -201,6 +205,84 @@ export const AllBuiltInModes: Story = {
render: () => ,
};
+// A host's catalogue: what it knows about each reference it can bind.
+const CATALOGUE: Record = {
+ '$vars.orderId': { label: 'Order id', icon: },
+ '$assets.apiKey': { label: 'API key', icon: },
+};
+
+// Called only while a picker is open, with the field it picks for.
+const hostPicker: FormVariablePicker = ({ field, value, onSelect }) => (
+
+ Host picker for {field.label}
+ {Object.entries(CATALOGUE).map(([reference, { label, icon }]) => (
+ onSelect(reference)}
+ className="flex items-center gap-2 rounded px-2 py-1 text-left hover:bg-surface-overlay aria-[current]:font-semibold [&_svg]:size-3.5"
+ >
+ {icon}
+ {label}
+
+ ))}
+
+);
+
+const hostPickerPlugin: FormPlugin = {
+ name: 'host-picker',
+ valueModes: {
+ definitions: {
+ variable: {
+ control: createVariableModeControl({
+ renderPicker: hostPicker,
+ describeReference: (reference) => CATALOGUE[reference],
+ }),
+ },
+ },
+ },
+ fieldActions: {
+ header: { 'insert-variable': createInsertVariableAction({ renderPicker: hostPicker }) },
+ },
+};
+
+const hostPickerSchema: FormSchema = {
+ id: 'host-picker',
+ title: 'Host picker',
+ initialData: {
+ orderId: { $mode: 'variable', value: '$vars.orderId' },
+ subject: 'Order ',
+ },
+ sections: [
+ {
+ id: 'main',
+ fields: [
+ {
+ name: 'orderId',
+ type: 'text',
+ label: 'Order id',
+ description: "The chip shows the host's name and type icon for the reference.",
+ valueModes: { modes: ['literal', 'expression', 'variable'] },
+ },
+ {
+ name: 'subject',
+ type: 'text',
+ label: 'Subject',
+ description: 'Insert variable opens the same host picker.',
+ valueModes: { modes: ['literal', 'expression', 'variable'] },
+ headerActions: ['insert-variable'],
+ },
+ ],
+ },
+ ],
+};
+
+/** The host's own picker in the Variable mode's panel and in Insert variable. */
+export const HostVariablePicker: Story = {
+ render: () => ,
+};
+
function PatternControl({ id, value, onChange, disabled, controlProps }: ValueModeControlProps) {
return (
VariableReferenceDescription | undefined;
+}
+
+type DescribeReference = (reference: string) => VariableReferenceDescription | undefined;
+
+// Picks its props from `ValueModeControlProps`, as the built-in registration does.
+const Control = VariableValueControl as unknown as React.ComponentType<
+ ValueModeControlProps & Pick
+>;
+
+/**
+ * The `variable` mode's control with the host's own picker or reference labels. Register it as
+ * `FormPlugin.valueModes.definitions.variable.control`; call this once, at module scope.
+ */
+export function createVariableModeControl({
+ renderPicker,
+ describeReference,
+}: VariableModeControlOptions): ValueModeControlRegistration {
+ const pickerFor = renderPicker && bindVariablePicker(renderPicker);
+ // Bound per field once, so the control's props stay stable across renders.
+ const described = new WeakMap();
+ const describerFor = (field: FieldMetadata) => {
+ if (!describeReference) return undefined;
+ let describe = described.get(field);
+ if (!describe) {
+ describe = (reference) => describeReference(reference, { field });
+ described.set(field, describe);
+ }
+ return describe;
+ };
+
+ function HostVariableValueControl(props: ValueModeControlProps) {
+ // MetadataForm always passes the field; the type allows a component used without one.
+ const { field } = props;
+ return (
+
+ );
+ }
+ return { component: HostVariableValueControl };
+}
diff --git a/packages/apollo-wind/src/components/ui/field-actions/field-actions.stories.tsx b/packages/apollo-wind/src/components/ui/field-actions/field-actions.stories.tsx
index fe6a689a8c..5e8d0af22a 100644
--- a/packages/apollo-wind/src/components/ui/field-actions/field-actions.stories.tsx
+++ b/packages/apollo-wind/src/components/ui/field-actions/field-actions.stories.tsx
@@ -4,7 +4,7 @@ import { FormField, FormFieldHeader } from '../form-field';
import { Input } from '../input';
import { InputGroup } from '../input-group';
import { TooltipProvider } from '../tooltip';
-import type { VariablePickerItem } from '../variable-picker';
+import type { RenderVariablePicker, VariablePickerItem } from '../variable-picker';
import { AiAssistAction } from './ai-assist-action';
import { InsertVariableAction } from './insert-variable-action';
@@ -21,6 +21,10 @@ Actions for a field's header row, passed to \`FormFieldHeader\`'s \`actions\`.
- **InsertVariableAction** opens a variable picker and reports the pick. It is disabled with nothing
to insert or nowhere to insert it, and collapses to its icon below a 260px \`@container\`.
- **AiAssistAction** opens a prompt and reports it. It needs an ancestor \`TooltipProvider\`.
+
+Insert variable can render the host's own picker instead, through \`renderPicker\`. The action
+keeps its trigger, popover, collapse, focus return and strings, and calls the slot only while the
+popover is open. The picker's \`onSelect\` commits and closes; \`close\` closes without a pick.
`,
},
},
@@ -165,3 +169,58 @@ export const InHeader: Story = {
);
},
};
+
+// Stand-in for a host's own picker, such as a virtualized one with live badges.
+const HOST_ROWS = [
+ { value: '$vars.orderId', label: 'Order id', live: true },
+ { value: '$assets.apiKey', label: 'API key', live: false },
+];
+
+const hostPicker: RenderVariablePicker = ({ value, onSelect, close }) => (
+
+ Host picker
+ {HOST_ROWS.map((row) => (
+ onSelect(row.value)}
+ className="flex items-center justify-between rounded px-2 py-1 text-left hover:bg-surface-overlay aria-[current]:font-semibold"
+ >
+ {row.label}
+ {row.live && Live }
+
+ ))}
+
+ Cancel
+
+
+);
+
+/** Insert variable rendering the host's own picker inside its popover. */
+export const HostPanels: Story = {
+ render: () => {
+ const [value, setValue] = useState('Order ');
+ return (
+
+ setValue((v) => (v ? `${v} ${variable}` : variable))}
+ />
+ }
+ />
+
+ setValue(e.target.value)}
+ />
+
+
+ );
+ },
+};
diff --git a/packages/apollo-wind/src/components/ui/field-actions/insert-variable-action.test.tsx b/packages/apollo-wind/src/components/ui/field-actions/insert-variable-action.test.tsx
index 54018f2c1e..8e55cd8cec 100644
--- a/packages/apollo-wind/src/components/ui/field-actions/insert-variable-action.test.tsx
+++ b/packages/apollo-wind/src/components/ui/field-actions/insert-variable-action.test.tsx
@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
-import type { VariablePickerItem } from '../variable-picker';
+import type { RenderVariablePicker, VariablePickerItem } from '../variable-picker';
import { InsertVariableAction } from './insert-variable-action';
describe('InsertVariableAction', () => {
@@ -75,4 +75,75 @@ describe('InsertVariableAction', () => {
expect(await screen.findByPlaceholderText('Rechercher')).toBeInTheDocument();
expect(screen.getByText('Aucune variable')).toBeInTheDocument();
});
+
+ describe('with a host picker', () => {
+ const hostPicker: RenderVariablePicker = ({ onSelect, close }) => (
+
+ onSelect('$host.orderId')}>
+ Host order id
+
+ onSelect('$host.item', { id: 'item', label: 'Item', metadata: 7 })}
+ >
+ Host item
+
+
+ Host cancel
+
+
+ );
+
+ it('is enabled without variables, and renders the host picker only once opened', async () => {
+ const user = userEvent.setup();
+ const render$ = vi.fn(hostPicker);
+ render( );
+ expect(render$).not.toHaveBeenCalled();
+ await user.click(screen.getByRole('button', { name: 'Insert variable' }));
+ expect(await screen.findByRole('button', { name: 'Host order id' })).toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('Search variables...')).toBeNull();
+ expect(render$.mock.calls[0]?.[0].value).toBeUndefined();
+ });
+
+ it('reports a pick, closes and returns focus to the trigger', async () => {
+ const user = userEvent.setup();
+ const onInsert = vi.fn();
+ render( );
+ const trigger = screen.getByRole('button', { name: 'Insert variable' });
+
+ await user.click(trigger);
+ await user.click(await screen.findByRole('button', { name: 'Host order id' }));
+ expect(onInsert).toHaveBeenCalledWith('$host.orderId', {
+ id: '$host.orderId',
+ label: '$host.orderId',
+ value: '$host.orderId',
+ });
+ expect(screen.queryByRole('button', { name: 'Host order id' })).toBeNull();
+ expect(trigger).toHaveFocus();
+
+ await user.click(trigger);
+ await user.click(await screen.findByRole('button', { name: 'Host item' }));
+ expect(onInsert).toHaveBeenLastCalledWith(
+ '$host.item',
+ expect.objectContaining({ id: 'item', metadata: 7 })
+ );
+ });
+
+ it('closes without reporting anything', async () => {
+ const user = userEvent.setup();
+ const onInsert = vi.fn();
+ render( );
+ const trigger = screen.getByRole('button', { name: 'Insert variable' });
+ await user.click(trigger);
+ await user.click(await screen.findByRole('button', { name: 'Host cancel' }));
+ expect(screen.queryByRole('button', { name: 'Host cancel' })).toBeNull();
+ expect(onInsert).not.toHaveBeenCalled();
+ expect(trigger).toHaveFocus();
+ });
+
+ it('is disabled with nowhere to insert', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'Insert variable' })).toBeDisabled();
+ });
+ });
});
diff --git a/packages/apollo-wind/src/components/ui/field-actions/insert-variable-action.tsx b/packages/apollo-wind/src/components/ui/field-actions/insert-variable-action.tsx
index 8ab8906e49..4a68802e1b 100644
--- a/packages/apollo-wind/src/components/ui/field-actions/insert-variable-action.tsx
+++ b/packages/apollo-wind/src/components/ui/field-actions/insert-variable-action.tsx
@@ -1,9 +1,16 @@
import { Braces, ChevronDown } from 'lucide-react';
import { useState } from 'react';
-import { VariablePicker, type VariablePickerItem } from '@/components/ui/variable-picker';
+import {
+ type RenderVariablePicker,
+ VariablePicker,
+ type VariablePickerItem,
+} from '@/components/ui/variable-picker';
import { cn } from '@/lib';
import { COLLAPSED_HIDDEN, COLLAPSED_ICON_PADDING } from './collapse';
+// Shared, so an omitted `variables` is the same list on every render.
+const NO_VARIABLES: VariablePickerItem[] = [];
+
export interface InsertVariableActionStrings {
/** Visible text, hidden when the action collapses to its icon. */
label: string;
@@ -29,16 +36,11 @@ export const DEFAULT_INSERT_VARIABLE_ACTION_STRINGS: InsertVariableActionStrings
replaceConfirm: 'Replace',
};
-export interface InsertVariableActionProps {
- /**
- * The variables to offer, rendered as supplied: the picker adds no root and no types. A function
- * is called each time the picker opens, never during render, so a host can hand over a live
- * source without re-rendering the field whenever it changes.
- */
- variables: VariablePickerItem[] | (() => VariablePickerItem[]);
+interface InsertVariableActionBaseProps {
/**
* Called with the picked variable's value, and the picked entry itself so a host can recover
- * whatever it attached to it. Omitted, the action is disabled.
+ * whatever it attached to it. A host picker that picks without an entry gets one made from the
+ * value. Omitted, the action is disabled.
*/
onInsert?: (value: string, item: VariablePickerItem) => void;
disabled?: boolean;
@@ -48,9 +50,29 @@ export interface InsertVariableActionProps {
strings?: Partial;
}
+export type InsertVariableActionProps = InsertVariableActionBaseProps &
+ (
+ | {
+ /**
+ * The variables to offer, rendered as supplied: the picker adds no root and no types. A
+ * function is called each time the picker opens, never during render, so a host can hand
+ * over a live source without re-rendering the field whenever it changes.
+ */
+ variables: VariablePickerItem[] | (() => VariablePickerItem[]);
+ renderPicker?: undefined;
+ }
+ | {
+ /** Ignored while `renderPicker` is set. */
+ variables?: VariablePickerItem[] | (() => VariablePickerItem[]);
+ /** The host's own picker, rendered in the popover in place of the built-in one. */
+ renderPicker: RenderVariablePicker;
+ }
+ );
+
/** The Insert-variable header action: opens a variable picker and reports the pick. */
export function InsertVariableAction({
- variables,
+ variables = NO_VARIABLES,
+ renderPicker,
onInsert,
disabled,
compact,
@@ -58,8 +80,9 @@ export function InsertVariableAction({
}: InsertVariableActionProps) {
const text = { ...DEFAULT_INSERT_VARIABLE_ACTION_STRINGS, ...strings };
const lazy = typeof variables === 'function';
- // A function's result is unknown until the picker opens, so only a supplied empty list disables.
- const inert = disabled || !onInsert || (!lazy && variables.length === 0);
+ // A function's result, or a host picker's, is unknown until the picker opens, so only a supplied
+ // empty list disables.
+ const inert = disabled || !onInsert || (!renderPicker && !lazy && variables.length === 0);
const [openedItems, setOpenedItems] = useState([]);
const collapsedTextClass = cn(COLLAPSED_HIDDEN, compact && '!hidden');
@@ -69,8 +92,9 @@ export function InsertVariableAction({
items={lazy ? openedItems : variables}
placeholder={text.searchPlaceholder}
emptyText={text.empty}
+ renderPicker={renderPicker}
onOpenChange={(open) => {
- if (open && lazy) setOpenedItems(variables());
+ if (open && lazy && !renderPicker) setOpenedItems(variables());
}}
onSelect={(variable) => {
if (variable.value) onInsert?.(variable.value, variable);
diff --git a/packages/apollo-wind/src/components/ui/reference-chip.tsx b/packages/apollo-wind/src/components/ui/reference-chip.tsx
index e9b2842df9..e5f1be7022 100644
--- a/packages/apollo-wind/src/components/ui/reference-chip.tsx
+++ b/packages/apollo-wind/src/components/ui/reference-chip.tsx
@@ -4,7 +4,7 @@ import { getReferenceChipColors } from './reference-chip-colors';
export interface ReferenceChipProps extends Omit, 'children'> {
/** Display name of the referenced variable, resource or connection. */
- label: string;
+ label: ReactNode;
/** Decorative icon. Branded icons may preserve their original colors. */
icon?: ReactNode;
onRemove?: () => void;
@@ -76,7 +76,7 @@ export const ReferenceChip = forwardRef(
{
event.preventDefault();
diff --git a/packages/apollo-wind/src/components/ui/variable-picker/index.ts b/packages/apollo-wind/src/components/ui/variable-picker/index.ts
index 7257a37b6a..6de0b8e305 100644
--- a/packages/apollo-wind/src/components/ui/variable-picker/index.ts
+++ b/packages/apollo-wind/src/components/ui/variable-picker/index.ts
@@ -1,6 +1,8 @@
export type {
+ RenderVariablePicker,
VariablePickerContentProps,
VariablePickerItem,
VariablePickerProps,
+ VariablePickerSlotProps,
} from './variable-picker';
export { VariablePicker, VariablePickerContent } from './variable-picker';
diff --git a/packages/apollo-wind/src/components/ui/variable-picker/variable-picker.test.tsx b/packages/apollo-wind/src/components/ui/variable-picker/variable-picker.test.tsx
index efa25f95b7..695496d90c 100644
--- a/packages/apollo-wind/src/components/ui/variable-picker/variable-picker.test.tsx
+++ b/packages/apollo-wind/src/components/ui/variable-picker/variable-picker.test.tsx
@@ -154,4 +154,41 @@ describe('VariablePicker', () => {
expect(screen.getByRole('option', { name: 'Run ID' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Customer name' })).not.toBeInTheDocument();
});
+
+ it("renders a host's picker in place of the tree, and closes on its pick", async () => {
+ const user = userEvent.setup();
+ const onSelect = vi.fn();
+ render(
+ (
+ <>
+ pick('$host.orderId')}>
+ Host order id
+
+
+ Host cancel
+
+ >
+ )}
+ />
+ );
+ const trigger = screen.getByRole('button', { name: 'Insert variable' });
+ await user.click(trigger);
+ expect(screen.queryByRole('option', { name: 'Customer name' })).not.toBeInTheDocument();
+ await user.click(await screen.findByRole('button', { name: 'Host order id' }));
+ expect(onSelect).toHaveBeenCalledWith({
+ id: '$host.orderId',
+ label: '$host.orderId',
+ value: '$host.orderId',
+ });
+ expect(screen.queryByRole('button', { name: 'Host order id' })).not.toBeInTheDocument();
+ expect(trigger).toHaveFocus();
+
+ await user.click(trigger);
+ await user.click(await screen.findByRole('button', { name: 'Host cancel' }));
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole('button', { name: 'Host cancel' })).not.toBeInTheDocument();
+ });
});
diff --git a/packages/apollo-wind/src/components/ui/variable-picker/variable-picker.tsx b/packages/apollo-wind/src/components/ui/variable-picker/variable-picker.tsx
index 9866a76825..5cc846c247 100644
--- a/packages/apollo-wind/src/components/ui/variable-picker/variable-picker.tsx
+++ b/packages/apollo-wind/src/components/ui/variable-picker/variable-picker.tsx
@@ -72,6 +72,36 @@ export interface VariablePickerProps extends VariablePickerContentProps {
onOpenChange?: (open: boolean) => void;
triggerLabel?: string;
triggerAriaLabel?: string;
+ /**
+ * The host's own picker, rendered in the popover in place of the search and tree, whose `items`
+ * it then does not use. A pick reaches `onSelect` as its entry, or one made from the value, with
+ * the picked value as its `value`.
+ */
+ renderPicker?: RenderVariablePicker;
+}
+
+/** What a host-rendered picker gets in place of the built-in search and tree. */
+export interface VariablePickerSlotProps {
+ /** The reference already bound, when the pick replaces one. */
+ value?: string;
+ /** Commits `value` and closes the popover. */
+ onSelect(value: string, item?: VariablePickerItem): void;
+ /** Closes the popover without committing anything. */
+ close(): void;
+}
+
+/**
+ * Renders a host's own picker inside a wind-owned popover. Called only while the popover is open:
+ * the trigger, anchoring, focus return and strings stay with wind.
+ */
+export type RenderVariablePicker = (props: VariablePickerSlotProps) => ReactNode;
+
+/** Renders a slot as an element, so it is called only once its popover mounts the content. */
+export function VariablePickerSlot({
+ render,
+ ...props
+}: VariablePickerSlotProps & { render: RenderVariablePicker }) {
+ return render(props);
}
function itemMatches(item: VariablePickerItem, normalizedQuery: string): boolean {
@@ -246,6 +276,7 @@ export function VariablePicker({
triggerAriaLabel = 'Insert variable',
className,
onSelect,
+ renderPicker,
...contentProps
}: VariablePickerProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
@@ -280,7 +311,17 @@ export function VariablePicker({
align={align}
className={cn('w-[280px] max-w-[calc(100vw-1rem)] overflow-hidden p-0', className)}
>
-
+ {renderPicker ? (
+
+ selectItem({ ...(item ?? { id: value, label: value }), value })
+ }
+ close={() => setOpen(false)}
+ />
+ ) : (
+
+ )}
);
diff --git a/packages/apollo-wind/src/components/ui/variable-value-control.stories.tsx b/packages/apollo-wind/src/components/ui/variable-value-control.stories.tsx
index c71a1e71f3..47d525315f 100644
--- a/packages/apollo-wind/src/components/ui/variable-value-control.stories.tsx
+++ b/packages/apollo-wind/src/components/ui/variable-value-control.stories.tsx
@@ -1,8 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Hash, KeyRound } from 'lucide-react';
import { useState } from 'react';
import { FormField, FormFieldLabel } from './form-field';
import { InputGroup } from './input-group';
-import { VariableValueControl } from './variable-value-control';
+import type { RenderVariablePicker } from './variable-picker';
+import { type VariableReferenceDescription, VariableValueControl } from './variable-value-control';
const meta = {
title: 'Components/Core/Variable Value Control',
@@ -15,6 +17,9 @@ const meta = {
A value bound to a variable: the whole control is a picker showing the bound reference, and picking
a variable replaces it. Render it in an \`InputGroup\`; the panel opens at the box's width. It is the
built-in control of MetadataForm's Variable mode.
+
+A host can render its own picker in the panel with \`renderPicker\`, which gets the bound reference
+as \`value\`, and show its own name and type icon for the reference with \`describeReference\`.
`,
},
},
@@ -64,3 +69,52 @@ export const Empty: Story = {
args: { onChange: () => {}, variables },
render: () => ,
};
+
+// A host's catalogue: what it knows about each reference it can bind.
+const CATALOGUE: Record = {
+ '$vars.orderId': { label: 'Order id', icon: },
+ '$assets.apiKey': { label: 'API key', icon: },
+};
+const describeReference = (reference: string) => CATALOGUE[reference];
+
+const hostPicker: RenderVariablePicker = ({ value, onSelect }) => (
+
+ Host picker
+ {Object.entries(CATALOGUE).map(([reference, { label, icon }]) => (
+ onSelect(reference)}
+ className="flex items-center gap-2 rounded px-2 py-1 text-left hover:bg-surface-overlay aria-[current]:font-semibold [&_svg]:size-3.5"
+ >
+ {icon}
+ {label}
+
+ ))}
+
+);
+
+function HostBound() {
+ const [value, setValue] = useState('$vars.orderId');
+ return (
+
+ Order id
+
+
+
+
+ );
+}
+
+/** The host's own picker in the panel, and its label and type icon on the chip. */
+export const HostPicker: Story = {
+ args: { onChange: () => {}, renderPicker: hostPicker },
+ render: () => ,
+};
diff --git a/packages/apollo-wind/src/components/ui/variable-value-control.test.tsx b/packages/apollo-wind/src/components/ui/variable-value-control.test.tsx
index 821b8a0259..c7f34a185e 100644
--- a/packages/apollo-wind/src/components/ui/variable-value-control.test.tsx
+++ b/packages/apollo-wind/src/components/ui/variable-value-control.test.tsx
@@ -1,8 +1,10 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { Hash } from 'lucide-react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { InputGroup } from './input-group';
+import type { RenderVariablePicker } from './variable-picker';
import { VariableValueControl } from './variable-value-control';
const variables = [{ id: 'order', label: 'Order id', value: '$vars.orderId' }];
@@ -93,4 +95,110 @@ describe('VariableValueControl', () => {
expect(await screen.findByPlaceholderText('Rechercher')).toBeInTheDocument();
expect(screen.getByText('Aucune')).toBeInTheDocument();
});
+
+ describe('with a host picker', () => {
+ const hostPicker: RenderVariablePicker = ({ value, onSelect, close }) => (
+
+
Bound: {value ?? 'nothing'}
+
onSelect('$host.orderId')}>
+ Host order id
+
+
+ Host cancel
+
+
+ );
+
+ it('hands it the bound reference, writes its pick and returns focus', async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
+
+
+
+ );
+ const trigger = screen.getByRole('button', { name: 'Ref' });
+ await user.click(trigger);
+ expect(await screen.findByText('Bound: $vars.old')).toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('Search variables...')).toBeNull();
+ await user.click(screen.getByRole('button', { name: 'Host order id' }));
+ expect(onChange).toHaveBeenCalledWith('$host.orderId');
+ expect(screen.queryByText('Bound: $vars.old')).toBeNull();
+ expect(trigger).toHaveFocus();
+ });
+
+ it('closes without writing', async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
+
+
+
+ );
+ const trigger = screen.getByRole('button', { name: 'Ref' });
+ await user.click(trigger);
+ expect(await screen.findByText('Bound: nothing')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'Host cancel' }));
+ expect(onChange).not.toHaveBeenCalled();
+ expect(trigger).toHaveFocus();
+ });
+ });
+
+ describe('describeReference', () => {
+ it("shows the host's label and icon in the chip and the trigger's name", () => {
+ const describe$ = vi.fn((reference: string) =>
+ reference === '$vars.orderId'
+ ? { label: 'Order id', icon: }
+ : undefined
+ );
+ const { rerender } = render(
+
+
+
+ );
+ const trigger = screen.getByRole('button', { name: 'Order id' });
+ expect(trigger).not.toHaveTextContent('$vars.orderId');
+ expect(trigger.querySelector('[data-slot="reference-chip"]')).toHaveTextContent('Order id');
+ expect(screen.getByTestId('number-icon')).toBeInTheDocument();
+ expect(describe$).toHaveBeenCalledWith('$vars.orderId');
+
+ rerender(
+
+
+
+ );
+ expect(screen.getByRole('button', { name: '$vars.gone' })).toBeInTheDocument();
+ expect(screen.queryByTestId('number-icon')).toBeNull();
+ });
+
+ it('is not asked while nothing is bound', () => {
+ const describe$ = vi.fn(() => ({ label: 'x' }));
+ render(
+
+
+
+ );
+ expect(describe$).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/packages/apollo-wind/src/components/ui/variable-value-control.tsx b/packages/apollo-wind/src/components/ui/variable-value-control.tsx
index c236d022b7..3887a28d9e 100644
--- a/packages/apollo-wind/src/components/ui/variable-value-control.tsx
+++ b/packages/apollo-wind/src/components/ui/variable-value-control.tsx
@@ -8,10 +8,25 @@ import {
import { InputGroupPopoverTrigger } from '@/components/ui/input-group';
import { Popover, PopoverContent } from '@/components/ui/popover';
import { ReferenceChip } from '@/components/ui/reference-chip';
-import { VariablePickerContent, type VariablePickerItem } from '@/components/ui/variable-picker';
+import {
+ type RenderVariablePicker,
+ VariablePickerContent,
+ type VariablePickerItem,
+} from '@/components/ui/variable-picker';
+import { VariablePickerSlot } from '@/components/ui/variable-picker/variable-picker';
import { composeRefs } from '@/lib';
-export interface VariableValueControlProps {
+// Shared, so an omitted `variables` keeps the callbacks that read it stable.
+const NO_VARIABLES: VariablePickerItem[] = [];
+
+/** How a bound reference is shown: the host's display name and type icon for it. */
+export interface VariableReferenceDescription {
+ label: React.ReactNode;
+ /** Defaults to the generic variable icon. */
+ icon?: React.ReactNode;
+}
+
+interface VariableValueControlBaseProps {
id?: string;
name?: string;
/** The bound reference, such as `$vars.orderId`. */
@@ -22,10 +37,10 @@ export interface VariableValueControlProps {
/** Also gets the trigger, as a form library's field ref does to focus an invalid field. */
inputRef?: React.Ref;
/**
- * The variables to pick from, rendered as supplied. A function is called each time the picker
- * opens, never during render.
+ * Describes the bound reference for its chip and the trigger's text. Returning nothing shows the
+ * reference as it is stored.
*/
- variables: VariablePickerItem[] | (() => VariablePickerItem[]);
+ describeReference?: (reference: string) => VariableReferenceDescription | undefined;
disabled?: boolean;
/** Shown while no variable is bound. Defaults to `strings.variablePlaceholder`. */
placeholder?: string;
@@ -39,6 +54,27 @@ export interface VariableValueControlProps {
controlProps?: { 'aria-describedby'?: string };
}
+export type VariableValueControlProps = VariableValueControlBaseProps &
+ (
+ | {
+ /**
+ * The variables to pick from, rendered as supplied. A function is called each time the
+ * picker opens, never during render.
+ */
+ variables: VariablePickerItem[] | (() => VariablePickerItem[]);
+ renderPicker?: undefined;
+ }
+ | {
+ /** Ignored while `renderPicker` is set. */
+ variables?: VariablePickerItem[] | (() => VariablePickerItem[]);
+ /**
+ * The host's own picker, rendered in the panel in place of the built-in one and handed the
+ * bound reference as `value`.
+ */
+ renderPicker: RenderVariablePicker;
+ }
+ );
+
/**
* A value bound to a variable, for an `InputGroup`: the whole control is a picker whose trigger
* shows the bound reference, and picking a variable replaces it. The panel opens at the box's
@@ -53,7 +89,9 @@ const VariableValueControl = React.forwardRef composeRefs(ref, inputRef), [ref, inputRef]);
+ const described = React.useMemo(
+ () => (reference && describeReference ? describeReference(reference) : undefined),
+ [reference, describeReference]
+ );
+
const changeOpen = React.useCallback(
(next: boolean) => {
- if (next) setItems(typeof variables === 'function' ? variables() : variables);
+ if (next && !renderPicker) {
+ setItems(typeof variables === 'function' ? variables() : variables);
+ }
setOpen(next);
},
- [variables]
+ [renderPicker, variables]
);
- const select = React.useCallback(
- (item: VariablePickerItem) => {
- if (item.value) onChange(item.value);
+ const close = React.useCallback(() => setOpen(false), []);
+ const commit = React.useCallback(
+ (next: string) => {
+ if (next) onChange(next);
setOpen(false);
},
[onChange]
);
+ const select = React.useCallback(
+ (item: VariablePickerItem) => commit(item.value ?? ''),
+ [commit]
+ );
return (
@@ -101,15 +151,29 @@ const VariableValueControl = React.forwardRef
- {reference && } />}
+ {reference && (
+ }
+ />
+ )}
-
+ {renderPicker ? (
+
+ ) : (
+
+ )}
);
diff --git a/packages/apollo-wind/src/index.ts b/packages/apollo-wind/src/index.ts
index 1008004290..9b49e04d53 100644
--- a/packages/apollo-wind/src/index.ts
+++ b/packages/apollo-wind/src/index.ts
@@ -22,6 +22,7 @@ export type {
FieldActionsPluginConfig,
FieldHeaderAction,
FieldMenuAction,
+ FormVariablePicker,
InsertVariableActionOptions,
ValueModeVariable,
} from './components/forms/field-actions';
@@ -119,6 +120,8 @@ export {
literalValues,
VALUE_MODE_OPAQUE,
} from './components/forms/value-modes';
+export type { VariableModeControlOptions } from './components/forms/variable-mode-control';
+export { createVariableModeControl } from './components/forms/variable-mode-control';
// -----------------------------------------------------------------------------
// Utility Components
// -----------------------------------------------------------------------------
@@ -1049,15 +1052,20 @@ export type {
} from './components/ui/tree-view';
export { default as FileTreeView, TreeView } from './components/ui/tree-view';
export type {
+ RenderVariablePicker,
VariablePickerContentProps,
VariablePickerItem,
VariablePickerProps,
+ VariablePickerSlotProps,
} from './components/ui/variable-picker';
export {
VariablePicker,
VariablePickerContent,
} from './components/ui/variable-picker';
-export type { VariableValueControlProps } from './components/ui/variable-value-control';
+export type {
+ VariableReferenceDescription,
+ VariableValueControlProps,
+} from './components/ui/variable-value-control';
export { VariableValueControl } from './components/ui/variable-value-control';
// -----------------------------------------------------------------------------
// Utilities