Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/apollo-wind/apollo-ai-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,9 @@ 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.
- Host picker: `VariablePicker`, `InsertVariableAction` and `VariableValueControl` take `renderPicker: RenderVariablePicker` (`({ value?, onSelect(value), 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.
- Host AI panel: `AiAssistAction` takes `renderContent({ close, disabled })` in place of its prompt form, called only while the popover is open.
- `FormFieldError severity="warning"` is a warning (`text-warning`): point the control's `aria-describedby` at it, not `aria-errormessage`, and leave the control valid.
- Localization: every addon and action takes a `strings` prop merged over its `DEFAULT_*_STRINGS`. There is no provider.

#### Tabs
Expand Down
5 changes: 4 additions & 1 deletion packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Same props as Row.
| `FormFieldHeader` | Label row with extras | `label`, `htmlFor`, `labelId`, `required`, `tooltip`, `leading`, `badge`, `actions`, `variant`: default\|muted |
| `InputGroup` | Box for control + addons | `layout`: row\|grow\|block\|fill, `variant`: default\|ghost\|outline\|none\|agent, `error`, `errorId`. Children: `InputGroupAddon` (`align`: inline-start\|inline-end), `InputGroupRow`, `InputGroupBody` |
| `FormFieldDescription` | Helper text | Children. Goes below the control |
| `FormFieldError` | Validation message | `id`. Uses the `text-error` token; renders nothing when empty |
| `FormFieldError` | Validation message | `id`, `severity`: error\|warning. Uses the `text-error` token, or `text-warning` for a warning; renders nothing when empty |
| `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` |
Expand Down Expand Up @@ -602,6 +602,9 @@ const columns: ColumnDef<Item>[] = [

Field validation messages use `FormFieldError`, which applies `text-error`. Some themes resolve
`destructive` and `error` to different colors, so never color a field error with `text-destructive`.
For a value that is accepted but likely wrong, use `<FormFieldError severity="warning">`
(`text-warning`), point the control's `aria-describedby` at it rather than `aria-errormessage`, and
leave the control valid.

---

Expand Down
9 changes: 6 additions & 3 deletions packages/apollo-wind/src/components/forms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ message.
| Control | `FieldControl`, `VariableValueControl`, `PromptValueControl`, or a registered control | `type`, the active mode, `valueModes.controls` |
| Menu | `FieldMenu` in a trailing `InputGroupAddon` | `valueModes.modes`, then `menuActions` |
| Description | `FormFieldDescription` | `description` |
| Message | `FormFieldError`, or the `error` prop of a control with a message slot | `validation` |
| Message | `FormFieldError` (`severity="warning"` for a warning), or the `error` prop of a control with a message slot | `validation` |

For a field outside any form, and inside a custom control, compose the same parts (see
[Custom Components](#-custom-components) for a registered control):
Expand Down Expand Up @@ -779,7 +779,10 @@ writes. Rules for any field, in a form or not:

- Compose a custom field or control from whichever of these parts apply. Never hand-roll label,
description or message markup, or wrap fields in a local shell.
- Field messages use `FormFieldError` (the `error` token), never `text-destructive`.
- Field messages use `FormFieldError` (the `error` token), never `text-destructive`. A warning, for
a value that is accepted but likely wrong, is `<FormFieldError severity="warning">` (the
`warning` token): point the control's `aria-describedby` at it, not `aria-errormessage`, and
leave the control valid.
- Actions go in the header, never inside the box.
- Localise with each component's `strings` prop, or `FormPlugin.strings` in a form. There is no
provider.
Expand Down Expand Up @@ -931,7 +934,7 @@ Register an action under the same id to replace a built-in.
`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,
bound reference in the `variable` mode, and `onSelect(reference)` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,6 @@ describe('InsertVariableAction', () => {
<button type="button" onClick={() => onSelect('$host.orderId')}>
Host order id
</button>
<button
type="button"
onClick={() => onSelect('$host.item', { id: 'item', label: 'Item', metadata: 7 })}
>
Host item
</button>
<button type="button" onClick={close}>
Host cancel
</button>
Expand Down Expand Up @@ -120,13 +114,6 @@ describe('InsertVariableAction', () => {
});
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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export const DEFAULT_INSERT_VARIABLE_ACTION_STRINGS: InsertVariableActionStrings
interface InsertVariableActionBaseProps {
/**
* Called with the picked variable's value, and the picked entry itself so a host can recover
* whatever it attached to it. A host picker that picks without an entry gets one made from the
* value. Omitted, the action is disabled.
* whatever it attached to it. A host picker's pick gets an entry made from the value. Omitted,
* the action is disabled.
*/
onInsert?: (value: string, item: VariablePickerItem) => void;
disabled?: boolean;
Expand Down
25 changes: 24 additions & 1 deletion packages/apollo-wind/src/components/ui/form-field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ in a metadata-driven form as in a hand-built panel.
- **FormFieldHeader** is the label row for a field whose header carries more than a label: a
leading glyph, a type badge and actions such as Insert variable. It sits outside the control's
box, so the actions never stretch it.
- **FormFieldDescription** and **FormFieldError** go below the control.
- **FormFieldDescription** and **FormFieldError** go below the control. \`severity="warning"\`
turns the message into a warning, for a value that is accepted but likely wrong.
`,
},
},
Expand Down Expand Up @@ -195,3 +196,25 @@ export const Header: Story = {
</div>
),
};

/**
* A warning below the control, for a value that is accepted but likely wrong. The control stays
* valid and points at the warning with `aria-describedby`.
*/
export const Warning: Story = {
render: () => (
<FormField className="w-96">
<FormFieldLabel htmlFor="warning-order-id">Order id</FormFieldLabel>
<InputGroup>
<Input
id="warning-order-id"
defaultValue="$vars.legacyOrderId"
aria-describedby="warning-order-id-message"
/>
</InputGroup>
<FormFieldError id="warning-order-id-message" severity="warning">
This variable is not set by any earlier step.
</FormFieldError>
</FormField>
),
};
16 changes: 16 additions & 0 deletions packages/apollo-wind/src/components/ui/form-field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,22 @@ describe('FormFieldError', () => {
expect(error).toHaveClass('mt-1.5');
expect(error).toHaveAttribute('data-slot', 'form-field-error');
});

it('uses the warning token for a warning, keeping the slot and polite announcement', () => {
render(<FormFieldError severity="warning">Variable no longer exists.</FormFieldError>);
const warning = screen.getByText('Variable no longer exists.');
expect(warning).toHaveClass('text-warning', 'mt-1.5');
expect(warning).not.toHaveClass('text-error');
expect(warning).toHaveAttribute('data-severity', 'warning');
expect(warning).toHaveAttribute('data-slot', 'form-field-error');
expect(warning).toHaveAttribute('aria-live', 'polite');
expect(warning).not.toHaveAttribute('role');
});

it('marks no severity on an error', () => {
render(<FormFieldError>Required.</FormFieldError>);
expect(screen.getByText('Required.')).not.toHaveAttribute('data-severity');
});
});

describe('FormFieldHeader', () => {
Expand Down
22 changes: 18 additions & 4 deletions packages/apollo-wind/src/components/ui/form-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,21 +109,35 @@ const FormFieldDescription = React.forwardRef<HTMLParagraphElement, FormFieldDes
);
FormFieldDescription.displayName = 'FormFieldDescription';

export type FormFieldErrorProps = React.ComponentPropsWithoutRef<'p'>;
export interface FormFieldErrorProps extends React.ComponentPropsWithoutRef<'p'> {
/**
* `warning` is for a value that is accepted but likely wrong, such as a reference that no longer
* resolves. Point the control's `aria-describedby` at a warning, not `aria-errormessage`, and
* leave it valid. Defaults to `error`.
*/
severity?: 'error' | 'warning';
}

/**
* Validation message rendered below a field control. Uses the `error` token
* rather than `destructive`, which some themes resolve to a different color.
* rather than `destructive`, which some themes resolve to a different color,
* and the `warning` token for a warning.
*/
const FormFieldError = React.forwardRef<HTMLParagraphElement, FormFieldErrorProps>(
({ children, className, ...props }, ref) => {
({ children, className, severity = 'error', ...props }, ref) => {
if (!children) return null;
return (
<p
ref={ref}
aria-live="polite"
aria-atomic="true"
className={cn('text-xs leading-4 text-error mt-1.5', className)}
data-severity={severity === 'warning' ? severity : undefined}
className={cn(
'text-xs leading-4',
severity === 'warning' ? 'text-warning' : 'text-error',
'mt-1.5',
className
)}
{...props}
// Always the canonical marker, even if a caller's own `data-slot` slips in through
// `...props` -- FormField's gap-cancelling rule above depends on this exact value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ export interface VariablePickerProps extends VariablePickerContentProps {
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`.
* it then does not use. A pick reaches `onSelect` as an entry made from the picked value.
*/
renderPicker?: RenderVariablePicker;
}
Expand All @@ -85,7 +84,7 @@ 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;
onSelect(value: string): void;
/** Closes the popover without committing anything. */
close(): void;
}
Expand Down Expand Up @@ -314,9 +313,7 @@ export function VariablePicker({
{renderPicker ? (
<VariablePickerSlot
render={renderPicker}
onSelect={(value, item) =>
selectItem({ ...(item ?? { id: value, label: value }), value })
}
onSelect={(value) => selectItem({ id: value, label: value, value })}
close={() => setOpen(false)}
/>
) : (
Expand Down
Loading