From 0f59e9e72b85fd8adc6c3cf6d3f509ec14f501fe Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Wed, 21 Oct 2026 17:44:30 -0700 Subject: [PATCH] feat(apollo-wind): host-rendered variable picker for Insert variable and Variable mode A host with its own variable picker can render it inside wind's popover, keeping wind's trigger, anchoring, chip, collapse, focus return and strings. - RenderVariablePicker slot type in ui/variable-picker; onSelect commits and closes - VariablePicker, InsertVariableAction and VariableValueControl take renderPicker; the actions then need no variables - VariableValueControl takes describeReference for the chip's label and type icon - ReferenceChip's label widens to ReactNode - createInsertVariableAction({ renderPicker }) wins over variables; picks still insert at the caret or ask before replacing - createVariableModeControl({ renderPicker, describeReference }) registers through valueModes.definitions.variable.control --- packages/apollo-wind/apollo-ai-context.md | 8 +- .../apollo-wind/docs/AI_CONSUMER_GUIDE.md | 10 +- .../src/components/forms/README.md | 12 +- .../forms/field-actions.stories.tsx | 43 ++++++ .../src/components/forms/field-actions.tsx | 54 ++++++- .../apollo-wind/src/components/forms/index.ts | 5 + .../forms/mode-aware-field.test.tsx | 138 ++++++++++++++++++ .../components/forms/value-modes.stories.tsx | 90 +++++++++++- .../forms/variable-mode-control.tsx | 64 ++++++++ .../field-actions/field-actions.stories.tsx | 61 +++++++- .../insert-variable-action.test.tsx | 73 ++++++++- .../field-actions/insert-variable-action.tsx | 50 +++++-- .../src/components/ui/reference-chip.tsx | 4 +- .../components/ui/variable-picker/index.ts | 2 + .../variable-picker/variable-picker.test.tsx | 37 +++++ .../ui/variable-picker/variable-picker.tsx | 43 +++++- .../ui/variable-value-control.stories.tsx | 56 ++++++- .../ui/variable-value-control.test.tsx | 108 ++++++++++++++ .../components/ui/variable-value-control.tsx | 100 ++++++++++--- packages/apollo-wind/src/index.ts | 10 +- 20 files changed, 915 insertions(+), 53 deletions(-) create mode 100644 packages/apollo-wind/src/components/forms/variable-mode-control.tsx 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) => ( + + ))} + +
+); + +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 }) => ( + + )); + 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 }]) => ( + + ))} +
+); + +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) => ( + + ))} + +
+); + +/** 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 }) => ( +
+ + + +
+ ); + + 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( + + + )} + /> + ); + 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 }]) => ( + + ))} +
+); + +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'}

+ + +
+ ); + + 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