editableOnValueChange?.(next)}
+ onBlur={onValueBlur}
+ inputRef={setModeControl}
+ disabled={!editableOnValueChange}
+ placeholder={placeholder}
+ strings={strings.valueModes}
+ aria-describedby={controlProps['aria-describedby']}
+ />
+ );
+
return (
- onInsertVariable
- ? onInsertVariable(variable, { value, caret: caretRef.current })
- : insertVariableAtCaret(variable.value)
+ // The variable picker is the value control there, so the header has nothing to insert into.
+ onInsertVariable={
+ builtInVariable
+ ? undefined
+ : (variable) =>
+ onInsertVariable
+ ? onInsertVariable(variable, { value, caret: caretRef.current })
+ : insertVariableAtCaret(variable.value)
}
headerActions={headerActions}
strings={strings}
@@ -416,11 +532,12 @@ export function QuickFormField({
error={error}
errorId={validationId}
disabled={disabled}
- // The dropzone and a text area are taller than a row and selected chips
- // wrap past one, so the box grows to hold them.
+ // The agent tint marks the value as a prompt. The prompt, the dropzone and a text area
+ // are taller than a row, so the box grows to hold them.
+ variant={builtInPrompt ? 'agent' : undefined}
layout={
- mode === 'literal' &&
- (fieldType === 'file' || fieldType === 'multi-select' || fieldType === 'textarea')
+ builtInPrompt ||
+ (mode === 'literal' && (fieldType === 'file' || fieldType === 'textarea'))
? 'grow'
: 'row'
}
@@ -447,6 +564,10 @@ export function QuickFormField({
) : (
)
+ ) : builtInVariable ? (
+ variableControl
+ ) : builtInPrompt ? (
+ promptControl
) : (
literalControl
))}
@@ -459,6 +580,24 @@ export function QuickFormField({
{belowValue}
+
+ setPendingSwitch(undefined)}
+ onConfirm={() => {
+ // The field may have been disabled while the dialog was open.
+ if (pendingSwitch && !disabled) {
+ focusControlOnClose.current = true;
+ onModeChange?.(pendingSwitch.to);
+ }
+ setPendingSwitch(undefined);
+ }}
+ onCloseAutoFocus={keepControlFocus}
+ />
);
}
diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/types.ts b/packages/apollo-wind/src/components/ui/quick-form-field/types.ts
index 5015623ff4..1398626309 100644
--- a/packages/apollo-wind/src/components/ui/quick-form-field/types.ts
+++ b/packages/apollo-wind/src/components/ui/quick-form-field/types.ts
@@ -14,7 +14,8 @@ import {
ToggleLeft,
} from 'lucide-react';
import type { AriaAttributes, ReactNode } from 'react';
-import type { ValueMode } from '../field-addons/value-mode-strings';
+import type { ValueMode, ValueModeStrings } from '../field-addons/value-mode-strings';
+import type { MultiSelectStrings } from '../multi-select';
// Wind's value-mode vocabulary: a literal, a JS expression, a bound workflow
// variable, or a prompt an agent fills in.
@@ -188,9 +189,21 @@ export interface QuickFormFieldStrings {
valueModeAriaLabel: string;
expressionLabel: string;
expressionDescription: string;
+ /**
+ * The variable and prompt modes' menu items, controls and placeholders, and the lossy-switch
+ * dialog. The literal and expression items keep `literalLabels`, `literalDescriptions`,
+ * `expressionLabel` and `expressionDescription`.
+ */
+ valueModes: Partial;
pickDate: string;
selectOption: string;
selectOptions: string;
+ /** The multi-select's search box, its empty state and its Clear all row, given the selected count. */
+ multiSelectSearchPlaceholder: string;
+ multiSelectEmpty: string;
+ multiSelectClearAll: (count: number) => string;
+ /** The multi-select's create row, "+N more" count and remove names. */
+ multiSelect: Partial;
moreActionsAriaLabel: string;
clearValue: string;
forceRefresh: string;
@@ -235,9 +248,14 @@ export const DEFAULT_QUICK_FORM_FIELD_STRINGS: QuickFormFieldStrings = {
valueModeAriaLabel: 'Choose value mode',
expressionLabel: 'Expression',
expressionDescription: 'Use a JS expression',
+ valueModes: {},
pickDate: 'Pick a date',
selectOption: 'Select an option',
selectOptions: 'Select options...',
+ multiSelectSearchPlaceholder: 'Search...',
+ multiSelectEmpty: 'No items found.',
+ multiSelectClearAll: (count) => `Clear all (${count})`,
+ multiSelect: {},
moreActionsAriaLabel: 'More value actions',
clearValue: 'Clear value',
forceRefresh: 'Force refresh',
@@ -270,6 +288,13 @@ export interface QuickFormFieldOption {
type?: string;
}
+/** Words the dialog confirming a lossy mode switch; each falls back to `strings.valueModes`. */
+export interface QuickFormFieldModeSwitchConfirm {
+ title?: string;
+ description?: string;
+ confirmLabel?: string;
+}
+
export interface QuickFormFieldMoreActions {
onClear?: () => void;
onRefresh?: () => void;
@@ -331,17 +356,31 @@ export interface QuickFormFieldProps {
*/
leadingAddon?: ReactNode;
/**
- * Replaces the trailing fixed/expression menu with a consumer-provided
- * action. Pass `null` to suppress the built-in
- * menu; omitting the prop preserves it.
+ * Replaces the trailing value-mode menu with a consumer-provided action.
+ * Pass `null` to suppress the built-in menu; omitting the prop preserves it.
*/
trailingAddon?: ReactNode;
/** Adds a field-level overflow menu beside the value control. */
more?: QuickFormFieldMoreActions;
/** How the value is expressed. Defaults to 'literal'. */
mode?: QuickFormFieldMode;
- /** Called when the user switches modes. */
+ /** Called when the user switches modes, after any confirmation `confirmModeChange` asks for. */
onModeChange?: (mode: QuickFormFieldMode) => void;
+ /**
+ * The modes the built-in menu offers, in display order. Defaults to literal
+ * and expression. With fewer than two the menu offers no switch.
+ */
+ valueModes?: readonly QuickFormFieldMode[];
+ /**
+ * Asks before a switch that would lose the current value. Only the consumer
+ * knows how its value maps between modes, so it decides: return `true` to
+ * confirm with the default dialog text, an object to word it, or `false` to
+ * switch straight away. Omit to never confirm.
+ */
+ confirmModeChange?: (
+ from: QuickFormFieldMode,
+ to: QuickFormFieldMode
+ ) => boolean | QuickFormFieldModeSwitchConfirm;
/**
* Optional expression editor used in place of the built-in monospace input.
* Consumers can use this to supply a syntax-aware editor such as Monaco.
@@ -352,13 +391,12 @@ export interface QuickFormFieldProps {
*/
renderExpressionEditor?: (props: QuickFormFieldControlProps) => ReactNode;
/**
- * Renders the value control for a given mode, replacing the built-in one.
- * `variable` and `prompt` have no built-in editor, and a surface that authors
- * its own choices can replace the `literal` control too.
+ * Renders the value control for a given mode, replacing the built-in one,
+ * such as a surface that authors its own choices in `literal` mode.
*
- * Returning nothing (or omitting this) falls back to the built-in control
- * for the field's type, so an unhandled mode degrades to the literal editor
- * rather than rendering an empty row.
+ * Returning nothing (or omitting this) keeps the built-in control for the
+ * mode: a picker over `variables` for `variable`, an agent prompt textarea
+ * for `prompt`, and the field type's own control otherwise.
*/
renderModeControl?: (mode: QuickFormFieldMode, props: QuickFormFieldControlProps) => ReactNode;
/**
@@ -369,6 +407,9 @@ export interface QuickFormFieldProps {
* A value that is one reference must be replaced rather than appended, and a
* variable may carry its type onto the field, so the consumer decides. Omit
* to splice at the caret, or append when no caret has been placed.
+ *
+ * Also called when a variable is picked in `variable` mode, where the value
+ * is that one reference and the pick replaces it.
*/
onInsertVariable?: (
variable: QuickFormFieldOption,
@@ -426,8 +467,9 @@ export interface QuickFormFieldProps {
*/
onGenerateWithAi?: (prompt: string) => void;
/**
- * Variables offered by the "Insert variable" popover; clicking one appends its value to the
- * current value. The button is disabled when this is empty (the default).
+ * Variables offered by the "Insert variable" popover, and by the picker in
+ * `variable` mode. Inserting one appends its value to the current value. The
+ * Insert button is disabled when this is empty (the default).
*/
variables?: QuickFormFieldOption[];
id?: string;
diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts b/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts
index 349fc0541a..fd888671bf 100644
--- a/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts
+++ b/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts
@@ -1,3 +1,4 @@
+import type { VariablePickerItem } from '@/components/ui/variable-picker';
import { toDateValue } from '@/lib/date-value';
import type { QuickFormFieldOption } from './types';
@@ -31,3 +32,29 @@ export function formatDateValue(value: string): string {
? date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
: value;
}
+
+export interface VariablePickerSource {
+ items: VariablePickerItem[];
+ /** The consumer's own entry for a picked item: rebuilding it from the item would drop `type`. */
+ resolve: (item: VariablePickerItem) => QuickFormFieldOption;
+}
+
+/** Maps `variables` onto picker items, keeping a way back to each consumer entry. */
+export function toVariablePickerSource(variables: QuickFormFieldOption[]): VariablePickerSource {
+ // Ids come from tree position: neither labels nor values are required to be unique.
+ const byId = new Map();
+ const toItem = (v: QuickFormFieldOption, id: string): VariablePickerItem => {
+ byId.set(id, v);
+ return {
+ id,
+ label: v.label,
+ ...(v.value ? { value: v.value } : {}),
+ ...(v.type ? { type: v.type } : {}),
+ ...(v.children ? { children: v.children.map((c, i) => toItem(c, `${id}/${i}`)) } : {}),
+ };
+ };
+ return {
+ items: variables.map((v, i) => toItem(v, String(i))),
+ resolve: (item) => byId.get(item.id) ?? { label: item.label, value: item.value ?? '' },
+ };
+}
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 c7f34a185e..c724d7c1b4 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
@@ -48,7 +48,10 @@ describe('VariableValueControl', () => {
await user.click(screen.getByRole('button', { name: 'Ref' }));
expect(source).toHaveBeenCalledTimes(1);
await user.click(await screen.findByText('Order id'));
- expect(onChange).toHaveBeenCalledWith('$vars.orderId');
+ expect(onChange).toHaveBeenCalledWith(
+ '$vars.orderId',
+ expect.objectContaining({ label: 'Order id', value: '$vars.orderId' })
+ );
expect(screen.queryByPlaceholderText('Search variables...')).toBeNull();
});
@@ -127,7 +130,8 @@ describe('VariableValueControl', () => {
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');
+ // A host pick is the reference alone: there is no entry to hand on.
+ expect(onChange).toHaveBeenCalledWith('$host.orderId', undefined);
expect(screen.queryByText('Bound: $vars.old')).toBeNull();
expect(trigger).toHaveFocus();
});
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 3887a28d9e..338b051966 100644
--- a/packages/apollo-wind/src/components/ui/variable-value-control.tsx
+++ b/packages/apollo-wind/src/components/ui/variable-value-control.tsx
@@ -31,7 +31,11 @@ interface VariableValueControlBaseProps {
name?: string;
/** The bound reference, such as `$vars.orderId`. */
value?: string;
- onChange: (reference: string) => void;
+ /**
+ * Also gets the picked entry, as supplied in `variables`, for a consumer that reads more than the
+ * reference, such as its type. A host picker reports only the reference, so its pick has none.
+ */
+ onChange: (reference: string, item?: VariablePickerItem) => void;
/** Called when the trigger loses focus, as a form library marks a field touched. */
onBlur?: () => void;
/** Also gets the trigger, as a form library's field ref does to focus an invalid field. */
@@ -127,16 +131,18 @@ const VariableValueControl = React.forwardRef setOpen(false), []);
const commit = React.useCallback(
- (next: string) => {
- if (next) onChange(next);
+ (next: string, item?: VariablePickerItem) => {
+ if (next) onChange(next, item);
setOpen(false);
},
[onChange]
);
const select = React.useCallback(
- (item: VariablePickerItem) => commit(item.value ?? ''),
+ (item: VariablePickerItem) => commit(item.value ?? '', item),
[commit]
);
+ // One argument, so nothing a host passes beyond the value can stand in for an entry.
+ const hostSelect = React.useCallback((next: string) => commit(next), [commit]);
return (
@@ -163,7 +169,7 @@ const VariableValueControl = React.forwardRef
) : (
diff --git a/packages/apollo-wind/src/index.ts b/packages/apollo-wind/src/index.ts
index e113add31a..3eb8120896 100644
--- a/packages/apollo-wind/src/index.ts
+++ b/packages/apollo-wind/src/index.ts
@@ -867,6 +867,7 @@ export type {
QuickFieldType,
QuickFormFieldControlProps,
QuickFormFieldMode,
+ QuickFormFieldModeSwitchConfirm,
QuickFormFieldMoreActions,
QuickFormFieldOption,
QuickFormFieldProps,