{
setFieldType(type);
setValue('');
- if (!FIELD_TYPE_META[type].supportsExpression) {
- setMode('literal');
- }
};
if (deleted) return null;
@@ -415,6 +407,33 @@ export const InlineValidation: Story = {
},
};
+export const Disabled: Story = {
+ render: () => (
+
+ {}}
+ onValueChange={() => {}}
+ onModeChange={() => {}}
+ onFieldTypeChange={() => {}}
+ onRequiredChange={() => {}}
+ variables={[{ label: 'Item ID', value: 'item.id' }]}
+ disabled
+ />
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Use `disabled` to stop the author editing the field. It disables the value control, the lock, the value-mode menu, and the header controls. A locked field stays editable: the lock only marks the value read-only for the person filling in the form.',
+ },
+ },
+ },
+};
+
const MODE_ITEMS: { mode: QuickFormFieldMode; label: string; icon: typeof Type }[] = [
{ mode: 'literal', label: 'Fixed value', icon: Type },
{ mode: 'expression', label: 'Expression', icon: Code2 },
@@ -690,9 +709,6 @@ function ResponsiveDemo() {
const handleFieldTypeChange = (type: QuickFieldType) => {
setFieldType(type);
setValue('');
- if (!FIELD_TYPE_META[type].supportsExpression) {
- setMode('literal');
- }
};
const label = (fieldId: string) => (
diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.test.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.test.tsx
index f5c6966bb4..6b46356399 100644
--- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.test.tsx
+++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.test.tsx
@@ -5,10 +5,46 @@ import { describe, expect, it, vi } from 'vitest';
import { QuickFormField } from './quick-form-field';
describe('QuickFormField', () => {
- it('renders a read-only display value when locked', () => {
- render( );
- expect(screen.getByPlaceholderText('String value')).toHaveValue('INV-2024-0587');
- expect(screen.getByPlaceholderText('String value')).toHaveAttribute('readonly');
+ it('keeps the value editable when locked', () => {
+ const handleChange = vi.fn();
+ render( );
+ const input = screen.getByPlaceholderText('String value');
+ expect(input).not.toHaveAttribute('readonly');
+ fireEvent.change(input, { target: { value: 'INV-2024-0588' } });
+ expect(handleChange).toHaveBeenCalledWith('INV-2024-0588');
+ });
+
+ it('disables every built-in control when disabled', () => {
+ render(
+
+ );
+ expect(screen.getByPlaceholderText('String value')).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Read-only' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Choose value mode' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Field type' })).toBeDisabled();
+ expect(screen.getByRole('switch', { name: 'Optional field' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'AI assist' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Insert variable' })).toBeDisabled();
+ });
+
+ it.each([
+ ['boolean', 'switch'],
+ ['single-select', 'combobox'],
+ ['multi-select', 'combobox'],
+ ] as const)('disables the %s control when disabled', (fieldType, role) => {
+ render( );
+ expect(screen.getByRole(role)).toBeDisabled();
});
it('renders an editable input when unlocked and onValueChange is provided', () => {
@@ -95,12 +131,12 @@ describe('QuickFormField', () => {
expect(handleBlur).toHaveBeenCalledOnce();
});
- it('withholds value changes from a consumer expression editor while locked', () => {
+ it('withholds value changes from a consumer expression editor while disabled', () => {
const renderExpressionEditor = vi.fn(() => Custom editor
);
render(
{
expect.objectContaining({
value: 'item.id',
readOnly: true,
+ disabled: true,
onValueChange: undefined,
})
);
});
- it('does not attach a value-change handler to the built-in expression input while locked', () => {
+ it('does not attach a value-change handler to the built-in expression input while disabled', () => {
const handleChange = vi.fn();
render(
-
+
);
- fireEvent.change(screen.getByDisplayValue('item.id'), { target: { value: 'other.id' } });
+ const input = screen.getByDisplayValue('item.id');
+ expect(input).toBeDisabled();
+ fireEvent.change(input, { target: { value: 'other.id' } });
expect(handleChange).not.toHaveBeenCalled();
});
@@ -191,49 +230,58 @@ describe('QuickFormField', () => {
expect(screen.getByRole('button', { name: 'Insert variable' })).toBeInTheDocument();
});
- it('only shows the value mode menu for types that support expressions', () => {
- const { rerender } = render( );
- expect(screen.queryByRole('button', { name: 'Choose value mode' })).not.toBeInTheDocument();
-
- rerender( );
- expect(screen.getByRole('button', { name: 'Choose value mode' })).toBeInTheDocument();
- });
-
- it('lists the More actions under the value mode menu', async () => {
+ it.each([
+ 'single-select',
+ 'multi-select',
+ ] as const)('offers expression mode for a %s field', async (fieldType) => {
const user = userEvent.setup();
+ const handleModeChange = vi.fn();
render(
);
await user.click(screen.getByRole('button', { name: 'Choose value mode' }));
+ await user.click(screen.getByRole('menuitemradio', { name: /Expression/ }));
+ expect(handleModeChange).toHaveBeenCalledWith('expression');
+ });
- expect(screen.getByRole('menuitemradio', { name: /Fixed value/ })).toBeInTheDocument();
- expect(screen.getByRole('menuitem', { name: 'Clear value' })).toBeInTheDocument();
- expect(screen.getByRole('menuitem', { name: 'Force refresh' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'More value actions' })).not.toBeInTheDocument();
+ it.each([
+ 'single-select',
+ 'multi-select',
+ ] as const)('renders the expression input for a %s field in expression mode', (fieldType) => {
+ render(
+
+ );
+ expect(screen.getByDisplayValue('$vars.choice')).toHaveClass('font-mono');
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
});
- it('offers only the More actions for a type with no expression mode', async () => {
+ it('lists the More actions under the value mode menu', async () => {
const user = userEvent.setup();
render(
);
- expect(screen.queryByRole('button', { name: 'Choose value mode' })).not.toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'More value actions' }));
+ await user.click(screen.getByRole('button', { name: 'Choose value mode' }));
+
+ expect(screen.getByRole('menuitemradio', { name: /Fixed value/ })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Clear value' })).toBeInTheDocument();
- expect(screen.queryByRole('menuitemradio')).not.toBeInTheDocument();
+ expect(screen.getByRole('menuitem', { name: 'Force refresh' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'More value actions' })).not.toBeInTheDocument();
});
it('offers only the More actions when there is no mode handler', async () => {
@@ -246,13 +294,13 @@ describe('QuickFormField', () => {
expect(screen.queryByRole('menuitemradio')).not.toBeInTheDocument();
});
- it('only renders available More actions and hides Clear value when locked', async () => {
+ it('keeps Clear value available when locked', async () => {
const user = userEvent.setup();
render( );
await user.click(screen.getByRole('button', { name: 'More value actions' }));
- expect(screen.queryByRole('menuitem', { name: 'Clear value' })).not.toBeInTheDocument();
+ expect(screen.getByRole('menuitem', { name: 'Clear value' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Force refresh' })).toBeInTheDocument();
});
@@ -355,8 +403,10 @@ describe('QuickFormField', () => {
expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'grow');
});
- it('keeps locked file fields in the compact read-only layout', () => {
- const { container } = render( );
+ it('keeps a file field in expression mode in the row layout', () => {
+ const { container } = render(
+
+ );
expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'row');
});
@@ -457,59 +507,43 @@ describe('QuickFormField', () => {
expect(screen.getByRole('button', { name: 'Supporting document' })).toBeInTheDocument();
});
- it('ignores non-string entries when parsing a locked multi-select value', () => {
+ it('ignores non-string entries when parsing a multi-select value', () => {
const options = [{ label: 'Alpha', value: 'alpha' }];
render(
);
- expect(screen.getByPlaceholderText('Multi select value')).toHaveValue('Alpha');
+ expect(screen.getByText('Alpha')).toBeInTheDocument();
+ expect(screen.queryByText('42')).not.toBeInTheDocument();
});
- it('falls back to the raw value for a locked multi-select value that parses to no entries', () => {
- render( );
- expect(screen.getByPlaceholderText('Multi select value')).toHaveValue('not-json');
- });
-
- it('shows an empty display for a locked multi-select value that is an explicit empty array', () => {
- render( );
- expect(screen.getByPlaceholderText('Multi select value')).toHaveValue('');
- });
-
- it('falls back to the raw value instead of throwing on an invalid date string', () => {
+ it('shows the raw value instead of throwing when unlocked with an invalid date', () => {
expect(() =>
- render( )
+ render( )
).not.toThrow();
- expect(screen.getByPlaceholderText('Date value')).toHaveValue('not-a-date');
- });
-
- it('shows the raw value instead of throwing when unlocked with an invalid date', () => {
- const { container } = render(
-
- );
- expect(container).toHaveTextContent('not-a-date');
+ expect(screen.getByRole('button', { name: 'Date value' })).toHaveTextContent('not-a-date');
});
it('rejects an out-of-range date-only value instead of silently normalizing it', () => {
- render( );
- expect(screen.getByPlaceholderText('Date value')).toHaveValue('2024-13-40');
+ render( );
+ expect(screen.getByRole('button', { name: 'Date value' })).toHaveTextContent('2024-13-40');
});
it('formats a date-only value using the local calendar day, not UTC', () => {
const originalTz = process.env.TZ;
process.env.TZ = 'America/Los_Angeles';
try {
- render( );
+ render( );
const expected = new Date(2024, 0, 15).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
- expect(screen.getByPlaceholderText('Date value')).toHaveValue(expected);
+ expect(screen.getByRole('button', { name: 'Date value' })).toHaveTextContent(expected);
} finally {
if (originalTz === undefined) {
delete process.env.TZ;
@@ -545,24 +579,6 @@ describe('QuickFormField', () => {
expect(screen.queryByText('Option 1')).not.toBeInTheDocument();
});
- it('shows a consumer-supplied option label for a locked single-select value', () => {
- const options = [{ label: 'Custom option', value: 'custom' }];
- render( );
- expect(screen.getByPlaceholderText('Single select value')).toHaveValue('Custom option');
- });
-
- it('falls back to the raw value for a locked single-select value not present in options', () => {
- render(
-
- );
- expect(screen.getByPlaceholderText('Single select value')).toHaveValue('stale-option');
- });
-
it('calls onGenerateWithAi with the entered prompt when Generate is clicked', async () => {
const user = userEvent.setup();
const handleGenerate = vi.fn();
@@ -575,12 +591,6 @@ describe('QuickFormField', () => {
expect(handleGenerate).toHaveBeenCalledWith('a random number');
});
- it('shows an empty display instead of "False" for an unset boolean value when locked', () => {
- const { container } = render( );
- expect(screen.getByPlaceholderText('Boolean value')).toHaveValue('');
- expect(container).not.toHaveTextContent('False');
- });
-
it('disables the value mode trigger when onModeChange is not provided', () => {
render( );
expect(screen.getByRole('button', { name: 'Choose value mode' })).toBeDisabled();
@@ -591,18 +601,14 @@ describe('QuickFormField', () => {
expect(screen.getByRole('button', { name: 'Choose value mode' })).not.toBeDisabled();
});
- it('reflects the current field type in the AI-assist output hint', async () => {
+ it.each([
+ ['date', 'literal', 'Output: Date value'],
+ ['single-select', 'expression', 'Output: Single select expression'],
+ ] as const)('describes a %s field in %s mode in the AI-assist output hint', async (fieldType, mode, hint) => {
const user = userEvent.setup();
- render( );
+ render( );
await user.click(screen.getByRole('button', { name: 'AI assist' }));
- expect(screen.getByText('Output: Date expression')).toBeInTheDocument();
- });
-
- it('shows a value (not expression) output hint for types that do not support expressions', async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole('button', { name: 'AI assist' }));
- expect(screen.getByText('Output: Single select value')).toBeInTheDocument();
+ expect(screen.getByText(hint)).toBeInTheDocument();
});
it('reflects the field type and mode in the default label', () => {
@@ -623,7 +629,7 @@ describe('QuickFormField', () => {
expect(screen.getByRole('button', { name: 'Insert variable' })).toBeDisabled();
});
- it('disables Insert variable while the field is locked', () => {
+ it('keeps Insert variable available while the field is locked', () => {
render(
{
variables={[{ label: 'Item ID', value: 'item.id' }]}
/>
);
- expect(screen.getByRole('button', { name: 'Insert variable' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Insert variable' })).not.toBeDisabled();
});
it('appends the selected variable to the current value when Insert variable is used', async () => {
@@ -720,13 +726,13 @@ describe('QuickFormField', () => {
);
});
- it('grows the multi-select box to hold wrapped chips, and only while unlocked', () => {
+ it('grows the multi-select box to hold wrapped chips, and only in literal mode', () => {
const { container, rerender } = render(
-
+
);
expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'grow');
- rerender( );
+ rerender( );
expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'row');
});
@@ -813,6 +819,7 @@ describe('QuickFormField', () => {
{
await user.click(screen.getByRole('button', { name: 'AI assist' }));
expect(screen.getByText('Ausgabe: Datum-Ausdruck')).toBeInTheDocument();
- await user.type(screen.getByRole('textbox'), 'next week');
+ await user.type(screen.getByLabelText('Describe what you want'), 'next week');
await user.click(screen.getByRole('button', { name: 'Generate' }));
expect(await screen.findByText('Wird generiert')).toBeInTheDocument();
reject(new Error('nope'));
expect(await screen.findByText('Fehlgeschlagen')).toBeInTheDocument();
});
-
- it('shows a locked boolean with the strings labels', () => {
- render(
-
- );
- expect(screen.getByDisplayValue('Wahr')).toBeInTheDocument();
- });
});
});
diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx
index 53596833a3..a8822563d6 100644
--- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx
+++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx
@@ -18,12 +18,11 @@ import { cn } from '@/lib';
import { FieldMenu, type FieldMenuItem } from '../field-addons/field-menu';
import { FieldHeader } from './components/field-header';
import { LockToggleButton } from './components/lock-toggle-button';
-import type { QuickFormFieldProps } from './types';
+import type { QuickFormFieldControlProps, QuickFormFieldProps } from './types';
import { DEFAULT_QUICK_FORM_FIELD_STRINGS, FIELD_TYPE_META } from './types';
import {
DEFAULT_SELECT_OPTIONS,
formatDateValue,
- getLockedDisplayValue,
parseDateValue,
parseListValue,
toDateOnlyString,
@@ -32,9 +31,9 @@ import {
/**
* A Quick Form is assembled by end users, like a form builder. QuickFormField is
* the control for an individual field within it, and carries its own
- * configuration controls: a lock that makes it read-only, a data-type switch
- * that swaps the control, a Required toggle, and a value-mode switch across
- * `literal`, `expression`, `variable` and `prompt`.
+ * configuration controls: a lock that marks it read-only for the person filling
+ * in the form, a data-type switch that swaps the control, a Required toggle, and
+ * a value-mode switch across `literal`, `expression`, `variable` and `prompt`.
*
* Only use it within a quick form builder. For standard forms, compose the
* form-field anatomy (`FormField`, `FormFieldLabel`, `InputGroup`, …) or
@@ -42,6 +41,8 @@ import {
*
* - Literal and expression have built-in controls; `renderModeControl`
* supplies the control for any mode, and `variable` / `prompt` need it.
+ * - `locked` is configuration the author sets, so the value stays editable;
+ * `disabled` is what stops the author editing the field.
* - `fieldTypes` chooses which types the picker offers.
* - Insert variable renders `variables` as supplied and splices at the caret;
* `onInsertVariable` takes over what inserting means.
@@ -60,6 +61,7 @@ export function QuickFormField({
locked = true,
onLockedChange,
showLock = true,
+ disabled = false,
leadingAddon,
trailingAddon,
more,
@@ -107,37 +109,31 @@ export function QuickFormField({
const fieldId = id ?? generatedId;
const validationId = errorId ?? `${fieldId}-error`;
const typeMeta = FIELD_TYPE_META[fieldType];
- // Only `expression` is coerced: a select cannot hold JS, but it can still be
- // bound to a variable or filled by a prompt.
- const effectiveMode = mode === 'expression' && !typeMeta.supportsExpression ? 'literal' : mode;
- const editableOnValueChange = locked ? undefined : onValueChange;
+ const editableOnValueChange = disabled ? undefined : onValueChange;
const typeLabel = strings.typeLabels[fieldType] ?? typeMeta.label;
const fieldLabel =
- effectiveMode === 'expression'
+ mode === 'expression'
? strings.expressionFieldLabel(typeLabel)
: strings.valueFieldLabel(typeLabel);
// The consumer's wording wins on the control; the computed name still labels
// the field for assistive tech.
const valuePlaceholder = placeholder ?? fieldLabel;
const menuActions: FieldMenuItem[] = [
- ...(more?.onClear && !locked
+ ...(more?.onClear
? [{ id: 'clear', label: strings.clearValue, icon: Trash2, onSelect: more.onClear }]
: []),
...(more?.onRefresh
? [{ id: 'refresh', label: strings.forceRefresh, icon: RefreshCw, onSelect: more.onRefresh }]
: []),
];
- // Modes and actions share one menu. A type with no expression mode, or a field with no mode
- // handler, offers only its actions; with neither, an expression-capable type keeps a disabled
- // mode trigger so the field still shows how its value is expressed.
- const offersModes = typeMeta.supportsExpression && !!onModeChange;
- const showsMenu = typeMeta.supportsExpression || menuActions.length > 0;
- const builtInMenu = showsMenu ? (
+ // Modes and actions share one menu. A field with no mode handler offers only its actions; with
+ // neither, it keeps a disabled mode trigger so the field still shows how its value is expressed.
+ const builtInMenu = (
onModeChange?.(next)}
- modesDisabled={!offersModes && menuActions.length > 0}
- disabled={!offersModes && menuActions.length === 0}
+ modesDisabled={!onModeChange && menuActions.length > 0}
+ disabled={disabled || (!onModeChange && menuActions.length === 0)}
actions={menuActions}
triggerLabel={strings.valueModeAriaLabel}
literalDescription={strings.literalDescriptions[fieldType] ?? typeMeta.fixedDescription}
@@ -148,25 +144,25 @@ export function QuickFormField({
fieldActions: strings.moreActionsAriaLabel,
}}
/>
- ) : null;
+ );
+
+ const controlProps: QuickFormFieldControlProps = {
+ id: fieldId,
+ value,
+ onValueChange: editableOnValueChange,
+ onBlur: onValueBlur,
+ readOnly: !editableOnValueChange,
+ disabled,
+ placeholder: valuePlaceholder,
+ fieldType,
+ 'aria-invalid': error ? true : undefined,
+ 'aria-describedby': error ? validationId : undefined,
+ 'aria-errormessage': error ? validationId : undefined,
+ 'data-slot': 'input-group-control',
+ };
- // Resolved before the tree so returning nothing falls back to the built-in
- // control. Offered the raw mode, not `effectiveMode`: the coercion protects
- // the built-in rendering and must not decide for a consumer with its own editor.
- const modeControl =
- renderModeControl?.(mode, {
- id: fieldId,
- value,
- onValueChange: editableOnValueChange,
- onBlur: onValueBlur,
- readOnly: !editableOnValueChange,
- placeholder: valuePlaceholder,
- fieldType,
- 'aria-invalid': error ? true : undefined,
- 'aria-describedby': error ? validationId : undefined,
- 'aria-errormessage': error ? validationId : undefined,
- 'data-slot': 'input-group-control',
- }) ?? null;
+ // Resolved before the tree so returning nothing falls back to the built-in control.
+ const modeControl = renderModeControl?.(mode, controlProps) ?? null;
// Switching mode is a prelude to writing the value, so focus the control.
// Skipped on mount so rendering a form cannot steal focus.
@@ -186,7 +182,7 @@ export function QuickFormField({
}
if (e.key === 'Escape') {
e.preventDefault();
- onValueChange?.(committedRef.current);
+ editableOnValueChange?.(committedRef.current);
e.currentTarget.blur();
}
};
@@ -214,10 +210,157 @@ export function QuickFormField({
});
};
- // Locked fields are read-only, not disabled — the raw control (switch, date
- // picker, select) has nothing left to do once editing is blocked, so it's
- // replaced with plain, selectable text showing the same value.
- const lockedDisplayValue = getLockedDisplayValue(fieldType, value, options, strings);
+ // Shared by the expression input and the literal text input, which both
+ // track the caret for Insert variable.
+ const textInputProps = {
+ id: fieldId,
+ ref: valueInputRef,
+ readOnly: !editableOnValueChange,
+ disabled,
+ value,
+ onChange: (e: React.ChangeEvent) => editableOnValueChange?.(e.target.value),
+ onFocus: (e: React.FocusEvent) => {
+ committedRef.current = value;
+ caretRef.current = {
+ start: e.currentTarget.selectionStart ?? value.length,
+ end: e.currentTarget.selectionEnd ?? value.length,
+ };
+ },
+ onSelect: (e: React.SyntheticEvent) => {
+ const el = e.currentTarget;
+ caretRef.current = {
+ start: el.selectionStart ?? 0,
+ end: el.selectionEnd ?? el.selectionStart ?? 0,
+ };
+ },
+ onKeyDown: handleValueKeyDown,
+ onBlur: (e: React.FocusEvent) => {
+ // Clicking the Insert trigger blurs this input, so this is the last
+ // moment the caret is knowable.
+ caretRef.current = {
+ start: e.currentTarget.selectionStart ?? value.length,
+ end: e.currentTarget.selectionEnd ?? value.length,
+ };
+ onValueBlur?.();
+ },
+ placeholder: valuePlaceholder,
+ };
+
+ const literalControl =
+ fieldType === 'boolean' ? (
+
+ editableOnValueChange?.(String(checked))}
+ onBlur={onValueBlur}
+ disabled={!editableOnValueChange}
+ />
+
+ ) : fieldType === 'date' ? (
+ {
+ setDatePopoverOpen(open);
+ if (!open) onValueBlur?.();
+ }}
+ >
+
+
+ {value ? (
+ formatDateValue(value)
+ ) : (
+ {strings.pickDate}
+ )}
+
+
+
+ editableOnValueChange?.(date ? toDateOnlyString(date) : '')}
+ initialFocus
+ />
+
+
+ ) : fieldType === 'datetime' ? (
+ // A timestamp needs the time of day, so it uses DateTimePicker rather
+ // than the date branch's Calendar popover. Stored as an ISO string.
+ editableOnValueChange?.(date ? date.toISOString() : '')}
+ onOpenChange={(open) => {
+ if (!open) onValueBlur?.();
+ }}
+ disabled={!editableOnValueChange}
+ placeholder={valuePlaceholder}
+ />
+ ) : fieldType === 'file' ? (
+ editableOnValueChange?.(files.map((f) => f.name).join(', '))}
+ disabled={!editableOnValueChange}
+ onBlur={onValueBlur}
+ aria-invalid={controlProps['aria-invalid']}
+ aria-describedby={controlProps['aria-describedby']}
+ aria-errormessage={controlProps['aria-errormessage']}
+ />
+ ) : fieldType === 'single-select' ? (
+ {
+ setSelectOpen(open);
+ if (!open && selectOpen) onValueBlur?.();
+ }}
+ value={value || undefined}
+ onValueChange={editableOnValueChange}
+ disabled={!editableOnValueChange}
+ >
+
+
+
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ ) : fieldType === 'multi-select' ? (
+ editableOnValueChange?.(JSON.stringify(selected))}
+ placeholder={strings.selectOptions}
+ disabled={!editableOnValueChange}
+ onBlur={onValueBlur}
+ />
+ ) : (
+
+ );
return (
- {typeMeta.supportsExpression ? (
-
- {(showLock || leadingAddon !== undefined) && leadingAddon !== null && (
-
- {leadingAddon !== undefined
- ? leadingAddon
- : showLock && (
-
- )}
-
- )}
-
- {modeControl ??
- (effectiveMode === 'expression' ? (
- renderExpressionEditor ? (
- renderExpressionEditor({
- id: fieldId,
- value,
- onValueChange: editableOnValueChange,
- onBlur: onValueBlur,
- readOnly: !editableOnValueChange,
- placeholder: valuePlaceholder,
- fieldType,
- 'aria-invalid': error ? true : undefined,
- 'aria-describedby': error ? validationId : undefined,
- 'aria-errormessage': error ? validationId : undefined,
- 'data-slot': 'input-group-control',
- })
- ) : (
- editableOnValueChange?.(e.target.value)}
- onFocus={(e) => {
- committedRef.current = value;
- caretRef.current = {
- start: e.currentTarget.selectionStart ?? value.length,
- end: e.currentTarget.selectionEnd ?? value.length,
- };
- }}
- onSelect={(e) => {
- const el = e.currentTarget;
- caretRef.current = {
- start: el.selectionStart ?? 0,
- end: el.selectionEnd ?? el.selectionStart ?? 0,
- };
- }}
- onKeyDown={handleValueKeyDown}
- onBlur={(e) => {
- caretRef.current = {
- start: e.currentTarget.selectionStart ?? value.length,
- end: e.currentTarget.selectionEnd ?? value.length,
- };
- onValueBlur?.();
- }}
- placeholder={valuePlaceholder}
- className="font-mono"
- />
- )
- ) : locked ? (
-
- ) : fieldType === 'boolean' ? (
-
- onValueChange?.(String(checked))}
- onBlur={onValueBlur}
- disabled={!onValueChange}
- />
-
- ) : fieldType === 'date' ? (
- {
- setDatePopoverOpen(open);
- if (!open) onValueBlur?.();
- }}
- >
-
-
- {value ? (
- formatDateValue(value)
- ) : (
- {strings.pickDate}
- )}
-
-
-
- onValueChange?.(date ? toDateOnlyString(date) : '')}
- initialFocus
+
+ {(showLock || leadingAddon !== undefined) && leadingAddon !== null && (
+
+ {leadingAddon !== undefined
+ ? leadingAddon
+ : showLock && (
+
-
-
- ) : fieldType === 'datetime' ? (
- // A timestamp needs the time of day, so it uses DateTimePicker rather
- // than the date branch's Calendar popover. Stored as an ISO string.
- onValueChange?.(date ? date.toISOString() : '')}
- onOpenChange={(open) => {
- if (!open) onValueBlur?.();
- }}
- disabled={!onValueChange}
- placeholder={valuePlaceholder}
- />
- ) : fieldType === 'file' ? (
- onValueChange?.(files.map((f) => f.name).join(', '))}
- disabled={!onValueChange}
- onBlur={onValueBlur}
- aria-invalid={error ? true : undefined}
- aria-describedby={error ? validationId : undefined}
- aria-errormessage={error ? validationId : undefined}
- />
- ) : (
- onValueChange?.(e.target.value)}
- onFocus={(e) => {
- committedRef.current = value;
- caretRef.current = {
- start: e.currentTarget.selectionStart ?? value.length,
- end: e.currentTarget.selectionEnd ?? value.length,
- };
- }}
- onSelect={(e) => {
- const el = e.currentTarget;
- caretRef.current = {
- start: el.selectionStart ?? 0,
- end: el.selectionEnd ?? el.selectionStart ?? 0,
- };
- }}
- onKeyDown={handleValueKeyDown}
- onBlur={(e) => {
- // Clicking the Insert trigger blurs this input, so this is the
- // last moment the caret is knowable.
- caretRef.current = {
- start: e.currentTarget.selectionStart ?? value.length,
- end: e.currentTarget.selectionEnd ?? value.length,
- };
- onValueBlur?.();
- }}
- placeholder={valuePlaceholder}
- />
- ))}
+ )}
+
+ )}
- {trailingAddon !== null && (trailingAddon !== undefined || builtInMenu) && (
-
- {trailingAddon !== undefined ? trailingAddon : builtInMenu}
-
- )}
-
- ) : (
-
- {(showLock || leadingAddon !== undefined) && leadingAddon !== null && (
-
- {leadingAddon !== undefined
- ? leadingAddon
- : showLock && (
-
- )}
-
- )}
+ {modeControl ??
+ (mode === 'expression' ? (
+ renderExpressionEditor ? (
+ renderExpressionEditor(controlProps)
+ ) : (
+
+ )
+ ) : (
+ literalControl
+ ))}
- {modeControl ??
- (locked ? (
-
- ) : fieldType === 'single-select' ? (
- {
- setSelectOpen(open);
- if (!open && selectOpen) onValueBlur?.();
- }}
- value={value || undefined}
- onValueChange={onValueChange}
- disabled={!onValueChange}
- >
-
-
-
-
- {options.map((option) => (
-
- {option.label}
-
- ))}
-
-
- ) : fieldType === 'multi-select' ? (
- onValueChange?.(JSON.stringify(selected))}
- placeholder={strings.selectOptions}
- disabled={!onValueChange}
- onBlur={onValueBlur}
- />
- ) : null)}
- {/* A select can still be bound to a variable, so the consumer's
- trailing control renders here too or it loses its mode switch. */}
- {trailingAddon !== null && (trailingAddon !== undefined || builtInMenu) && (
-
- {trailingAddon !== undefined ? trailingAddon : builtInMenu}
-
- )}
-
- )}
+ {trailingAddon !== null && (
+
+ {trailingAddon !== undefined ? trailingAddon : builtInMenu}
+
+ )}
+
{belowValue}
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 c9616a4f8e..6928824c28 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
@@ -37,6 +37,10 @@ export type QuickFieldType =
interface FieldTypeMeta {
label: string;
icon: LucideIcon;
+ /**
+ * @deprecated Will expand mode support in future update. Currently unused.
+ * Removed in the next major release.
+ */
supportsExpression: boolean;
fixedLabel: string;
fixedDescription: string;
@@ -88,14 +92,14 @@ export const FIELD_TYPE_META: Record = {
'single-select': {
label: 'Single select',
icon: List,
- supportsExpression: false,
+ supportsExpression: true,
fixedLabel: 'Fixed value',
fixedDescription: 'Choose one option',
},
'multi-select': {
label: 'Multi select',
icon: ListChecks,
- supportsExpression: false,
+ supportsExpression: true,
fixedLabel: 'Fixed value',
fixedDescription: 'Choose one or more options',
},
@@ -156,8 +160,9 @@ export interface QuickFormFieldStrings {
valueFieldLabel: (typeLabel: string) => string;
/** The computed field name in expression mode, from the (localized) type label. */
expressionFieldLabel: (typeLabel: string) => string;
- /** A locked boolean's displayed text. */
+ /** @deprecated No longer rendered: a locked boolean keeps its Switch. Removed in the next major release. */
trueLabel: string;
+ /** @deprecated No longer rendered: a locked boolean keeps its Switch. Removed in the next major release. */
falseLabel: string;
requiredTooltip: string;
requiredAriaLabel: string;
@@ -268,33 +273,60 @@ export interface VariableInsertContext {
caret: { start: number; end: number } | null;
}
+/** What a consumer-rendered value control receives from `renderModeControl` or `renderExpressionEditor`. */
+export interface QuickFormFieldControlProps {
+ id: string;
+ value: string;
+ /** Omitted while the field is disabled or has no `onValueChange`. */
+ onValueChange?: (value: string) => void;
+ onBlur?: () => void;
+ readOnly: boolean;
+ disabled: boolean;
+ placeholder: string;
+ fieldType: QuickFieldType;
+ 'aria-invalid'?: AriaAttributes['aria-invalid'];
+ 'aria-describedby'?: string;
+ 'aria-errormessage'?: string;
+ 'data-slot'?: string;
+}
+
export interface QuickFormFieldProps {
/** Current field value. Encoding depends on fieldType (e.g. multi-select is a JSON array string). */
value?: string;
- /** Called when the user edits the value (only fires while unlocked). */
+ /** Called when the user edits the value. Never fires while `disabled`. */
onValueChange?: (value: string) => void;
/** Called when the active value control loses focus. */
onValueBlur?: () => void;
- /** Whether the field is read-only. Defaults to true. */
+ /**
+ * Whether the value is read-only for the person filling in the form. It is
+ * configuration the author sets, so the value stays editable here; use
+ * `disabled` to stop editing. Defaults to true.
+ */
locked?: boolean;
/** Called when the user toggles the lock. */
onLockedChange?: (locked: boolean) => void;
/** Whether the built-in lock control renders. Defaults to true. */
showLock?: boolean;
+ /**
+ * Disables every built-in control: the value control, the lock, the mode and
+ * actions menu, and the header's type picker, Required switch, Insert
+ * variable and AI assist. `headerActions` are the consumer's to disable.
+ */
+ disabled?: boolean;
/**
* Replaces the leading lock toggle with a semantic prefix such as `=`.
* Pass `null` to suppress the built-in toggle; omitting the prop preserves it.
*/
leadingAddon?: ReactNode;
/**
- * Replaces the trailing fixed/expression menu with a consumer-provided action
- * for expression-capable field types. Pass `null` to suppress the built-in
+ * Replaces the trailing fixed/expression 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'; `expression` falls back to `literal` for types that cannot hold one. */
+ /** How the value is expressed. Defaults to 'literal'. */
mode?: QuickFormFieldMode;
/** Called when the user switches modes. */
onModeChange?: (mode: QuickFormFieldMode) => void;
@@ -306,19 +338,7 @@ export interface QuickFormFieldProps {
* existing callers; `renderModeControl` wins when both are supplied and it
* returns something for `expression`.
*/
- renderExpressionEditor?: (props: {
- id: string;
- value: string;
- onValueChange?: (value: string) => void;
- onBlur?: () => void;
- readOnly: boolean;
- placeholder: string;
- fieldType: QuickFieldType;
- 'aria-invalid'?: AriaAttributes['aria-invalid'];
- 'aria-describedby'?: string;
- 'aria-errormessage'?: string;
- 'data-slot'?: string;
- }) => ReactNode;
+ 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
@@ -328,22 +348,7 @@ export interface QuickFormFieldProps {
* for the field's type, so an unhandled mode degrades to the literal editor
* rather than rendering an empty row.
*/
- renderModeControl?: (
- mode: QuickFormFieldMode,
- props: {
- id: string;
- value: string;
- onValueChange?: (value: string) => void;
- onBlur?: () => void;
- readOnly: boolean;
- placeholder: string;
- fieldType: QuickFieldType;
- 'aria-invalid'?: AriaAttributes['aria-invalid'];
- 'aria-describedby'?: string;
- 'aria-errormessage'?: string;
- 'data-slot'?: string;
- }
- ) => ReactNode;
+ renderModeControl?: (mode: QuickFormFieldMode, props: QuickFormFieldControlProps) => ReactNode;
/**
* Takes over what choosing a variable does to the value. The component owns
* the picker and hands back the chosen entry exactly as supplied in
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 d95ed8be71..a41a6c64e7 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,4 +1,4 @@
-import type { QuickFieldType, QuickFormFieldOption } from './types';
+import type { QuickFormFieldOption } from './types';
export const DEFAULT_SELECT_OPTIONS: QuickFormFieldOption[] = [
{ label: 'Option 1', value: 'option-1' },
@@ -59,59 +59,3 @@ export function formatDateValue(value: string): string {
? date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
: value;
}
-
-// Separate from formatDateValue so the date-only output stays unchanged.
-/** Formats a datetime field's value, falling back to the raw value when unparseable. */
-export function formatDateTimeValue(value: string): string {
- const date = parseDateValue(value);
- return date
- ? date.toLocaleString(undefined, {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- hour: 'numeric',
- minute: '2-digit',
- })
- : value;
-}
-
-/**
- * Computes the plain-text shown in place of the real control once a field is locked.
- * Boolean/single-select/multi-select resolve their stored value to a display label;
- * everything else (including an invalid date, so the component never throws on
- * external input) falls back to the raw value.
- */
-export function getLockedDisplayValue(
- fieldType: QuickFieldType,
- value: string,
- options: QuickFormFieldOption[],
- booleanLabels: { trueLabel: string; falseLabel: string } = {
- trueLabel: 'True',
- falseLabel: 'False',
- }
-): string {
- switch (fieldType) {
- case 'boolean':
- if (value === 'true') return booleanLabels.trueLabel;
- if (value === 'false') return booleanLabels.falseLabel;
- return '';
- case 'date':
- return value ? formatDateValue(value) : '';
- case 'datetime':
- return value ? formatDateTimeValue(value) : '';
- case 'single-select':
- return options.find((option) => option.value === value)?.label ?? value;
- case 'multi-select': {
- const parsed = parseListValue(value);
- // A malformed value (not JSON, or an array with no string entries) also
- // parses to an empty list -- fall back to the raw value so it's still
- // visible, rather than rendering as if the field were genuinely empty.
- if (parsed.length === 0 && value && value !== '[]') {
- return value;
- }
- return parsed.map((v) => options.find((option) => option.value === v)?.label ?? v).join(', ');
- }
- default:
- return value;
- }
-}
diff --git a/packages/apollo-wind/src/index.ts b/packages/apollo-wind/src/index.ts
index a4ae265b5e..4f7b25512b 100644
--- a/packages/apollo-wind/src/index.ts
+++ b/packages/apollo-wind/src/index.ts
@@ -647,6 +647,7 @@ export type { PromptValueControlProps } from './components/ui/prompt-value-contr
export { PromptValueControl } from './components/ui/prompt-value-control';
export type {
QuickFieldType,
+ QuickFormFieldControlProps,
QuickFormFieldMode,
QuickFormFieldMoreActions,
QuickFormFieldOption,