diff --git a/packages/apollo-wind/apollo-ai-context.md b/packages/apollo-wind/apollo-ai-context.md index c5e4bdd57..5ae8f9f9c 100644 --- a/packages/apollo-wind/apollo-ai-context.md +++ b/packages/apollo-wind/apollo-ai-context.md @@ -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 diff --git a/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md b/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md index 378984c92..b5dfdc363 100644 --- a/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md +++ b/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md @@ -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` | @@ -602,6 +602,9 @@ const columns: ColumnDef[] = [ 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 `` +(`text-warning`), point the control's `aria-describedby` at it rather than `aria-errormessage`, and +leave the control valid. --- diff --git a/packages/apollo-wind/src/components/forms/README.md b/packages/apollo-wind/src/components/forms/README.md index 70cb7ea04..256fe973d 100644 --- a/packages/apollo-wind/src/components/forms/README.md +++ b/packages/apollo-wind/src/components/forms/README.md @@ -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): @@ -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 `` (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. @@ -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 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 8e55cd8ce..28ae07256 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 @@ -82,12 +82,6 @@ describe('InsertVariableAction', () => { - @@ -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 () => { 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 4a68802e1..e838ecf8c 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 @@ -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; diff --git a/packages/apollo-wind/src/components/ui/form-field.stories.tsx b/packages/apollo-wind/src/components/ui/form-field.stories.tsx index 3cf60405f..2eb54959e 100644 --- a/packages/apollo-wind/src/components/ui/form-field.stories.tsx +++ b/packages/apollo-wind/src/components/ui/form-field.stories.tsx @@ -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. `, }, }, @@ -195,3 +196,25 @@ export const Header: Story = { ), }; + +/** + * 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: () => ( + + Order id + + + + + This variable is not set by any earlier step. + + + ), +}; diff --git a/packages/apollo-wind/src/components/ui/form-field.test.tsx b/packages/apollo-wind/src/components/ui/form-field.test.tsx index 13e786ec5..8ad5ea701 100644 --- a/packages/apollo-wind/src/components/ui/form-field.test.tsx +++ b/packages/apollo-wind/src/components/ui/form-field.test.tsx @@ -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(Variable no longer exists.); + 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(Required.); + expect(screen.getByText('Required.')).not.toHaveAttribute('data-severity'); + }); }); describe('FormFieldHeader', () => { diff --git a/packages/apollo-wind/src/components/ui/form-field.tsx b/packages/apollo-wind/src/components/ui/form-field.tsx index a4e2cd85b..8d816b913 100644 --- a/packages/apollo-wind/src/components/ui/form-field.tsx +++ b/packages/apollo-wind/src/components/ui/form-field.tsx @@ -109,21 +109,35 @@ const FormFieldDescription = React.forwardRef; +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( - ({ children, className, ...props }, ref) => { + ({ children, className, severity = 'error', ...props }, ref) => { if (!children) return null; return (

- selectItem({ ...(item ?? { id: value, label: value }), value }) - } + onSelect={(value) => selectItem({ id: value, label: value, value })} close={() => setOpen(false)} /> ) : (