From e7a8e327f31c3dbafa32d2ea0c18232dfe98c614 Mon Sep 17 00:00:00 2001 From: Pedro Bonamin <46196328+pedrobonamin@users.noreply.github.com> Date: Wed, 13 Sep 2023 16:40:51 +0200 Subject: [PATCH] feat(WCAG): Associate field descriptions to inputs (#4896) * feat(WCAG): Associate field descriptions to inputs * feat(WCAG): Adds aria-describedby to PTE * feat(WCAG): Rename PT prop and change constructDescriptionId for createDescriptionId * fix(portable-text-editor): forward html attributes to editable component This will forward html props for the editable element to the slate editable. It will also get rid of the need of having an own wrapper div element to put the ref on. Point it directly on the editable element. * test(portable-text-editor): update snapshot * refactor(portable-text-editor): remove unnecessary memo This doesn't need to be memoed, at least not after supporting restProps here. Debugging shows that the nodes inside are not re-rendered by removing this memo, which is what we care about. --------- Co-authored-by: Per-Kristian Nordnes --- .../src/editor/Editable.tsx | 68 ++++++-------- .../__tests__/PortableTextEditor.test.tsx | 93 +++++++++---------- .../__tests__/PortableTextEditorTester.tsx | 1 + .../formField/FormFieldHeaderText.tsx | 3 +- .../components/formField/FormFieldSet.tsx | 5 +- .../form/inputs/PortableText/Compositor.tsx | 4 +- .../core/form/inputs/PortableText/Editor.tsx | 4 + .../inputs/ReferenceInput/ReferenceField.tsx | 1 + .../inputs/ReferenceInput/ReferenceItem.tsx | 1 + .../array/items/ArrayOfObjectsItem.tsx | 4 +- .../array/items/ArrayOfPrimitivesItem.tsx | 2 + .../members/common/createDescriptionId.ts | 14 +++ .../form/members/object/MemberFieldset.tsx | 1 + .../object/fields/ArrayOfObjectsField.tsx | 4 +- .../object/fields/ArrayOfPrimitivesField.tsx | 4 +- .../members/object/fields/ObjectField.tsx | 4 +- .../members/object/fields/PrimitiveField.tsx | 2 + .../src/core/form/studio/FormBuilder.tsx | 8 +- .../studio/inputResolver/fieldResolver.tsx | 2 + .../sanity/src/core/form/types/inputProps.ts | 2 + 20 files changed, 132 insertions(+), 95 deletions(-) create mode 100644 packages/sanity/src/core/form/members/common/createDescriptionId.ts diff --git a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx index be405c9d081..e8292fdc256 100644 --- a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx @@ -52,7 +52,10 @@ const EMPTY_DECORATORS: BaseRange[] = [] /** * @public */ -export type PortableTextEditableProps = { +export type PortableTextEditableProps = Omit< + React.TextareaHTMLAttributes, + 'onPaste' | 'onCopy' +> & { hotkeys?: HotkeyOptions onBeforeInput?: OnBeforeInputFn onPaste?: OnPasteFn @@ -387,48 +390,33 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( return EMPTY_DECORATORS }, [schemaTypes, slateEditor]) - // The editor - const slateEditable = useMemo( - () => ( - - ), - [ - decorate, - handleCopy, - handleKeyDown, - handleOnBeforeInput, - handleOnBlur, - handleOnFocus, - handlePaste, - props.style, - readOnly, - renderElement, - renderLeaf, - scrollSelectionIntoViewToSlate, - ], - ) + // Set the forwarded ref to be the Slate editable DOM element + useEffect(() => { + ref.current = ReactEditor.toDOMNode(slateEditor, slateEditor) as HTMLDivElement | null + }, [slateEditor, ref]) if (!portableTextEditor) { return null } - return ( -
- {hasInvalidValue ? null : slateEditable} -
+ return hasInvalidValue ? null : ( + ) }) diff --git a/packages/@sanity/portable-text-editor/src/editor/__tests__/PortableTextEditor.test.tsx b/packages/@sanity/portable-text-editor/src/editor/__tests__/PortableTextEditor.test.tsx index c7957a2bc5e..13b4c3d15fc 100644 --- a/packages/@sanity/portable-text-editor/src/editor/__tests__/PortableTextEditor.test.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/__tests__/PortableTextEditor.test.tsx @@ -35,57 +35,56 @@ describe('initialization', () => { expect(onChange).toHaveBeenCalledWith({type: 'ready'}) expect(onChange).toHaveBeenCalledWith({type: 'value', value: undefined}) expect(container).toMatchInlineSnapshot(` +
+
+
+
-
-
+ -
+ + -
-
- - - Jot something down here - - - -  -
-
-
-
-
-
-
-
-
+  +
+ + +
- `) +
+
+
+
+`) }) }) it('takes value from props', async () => { diff --git a/packages/@sanity/portable-text-editor/src/editor/__tests__/PortableTextEditorTester.tsx b/packages/@sanity/portable-text-editor/src/editor/__tests__/PortableTextEditorTester.tsx index f21bcfef161..a301389da17 100644 --- a/packages/@sanity/portable-text-editor/src/editor/__tests__/PortableTextEditorTester.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/__tests__/PortableTextEditorTester.tsx @@ -73,6 +73,7 @@ export const PortableTextEditorTester = forwardRef(function PortableTextEditorTe ) diff --git a/packages/sanity/src/core/form/components/formField/FormFieldHeaderText.tsx b/packages/sanity/src/core/form/components/formField/FormFieldHeaderText.tsx index f287b1d943b..049b46560f2 100644 --- a/packages/sanity/src/core/form/components/formField/FormFieldHeaderText.tsx +++ b/packages/sanity/src/core/form/components/formField/FormFieldHeaderText.tsx @@ -3,6 +3,7 @@ import {FormNodeValidation} from '@sanity/types' import {Box, Flex, Stack, Text} from '@sanity/ui' import React, {memo} from 'react' +import {createDescriptionId} from '../../members/common/createDescriptionId' import {FormFieldValidationStatus} from './FormFieldValidationStatus' /** @internal */ @@ -45,7 +46,7 @@ export const FormFieldHeaderText = memo(function FormFieldHeaderText( {description && ( - + {description} )} diff --git a/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx b/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx index e77f171c44c..81b98067e6e 100644 --- a/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx +++ b/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx @@ -6,6 +6,7 @@ import {FormNodeValidation} from '@sanity/types' import {FormNodePresence} from '../../../presence' import {DocumentFieldActionNode} from '../../../config' import {useFieldActions} from '../../field' +import {createDescriptionId} from '../../members/common/createDescriptionId' import {FormFieldValidationStatus} from './FormFieldValidationStatus' import {FormFieldSetLegend} from './FormFieldSetLegend' import {focusRingStyle} from './styles' @@ -43,6 +44,7 @@ export interface FormFieldSetProps { * @beta */ validation?: FormNodeValidation[] + inputId: string } function getChildren(children: React.ReactNode | (() => React.ReactNode)): React.ReactNode { @@ -109,6 +111,7 @@ export const FormFieldSet = forwardRef(function FormFieldSet( tabIndex, title, validation = EMPTY_ARRAY, + inputId, ...restProps } = props @@ -170,7 +173,7 @@ export const FormFieldSet = forwardRef(function FormFieldSet( {description && ( - + {description} )} diff --git a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx index 3525e7868bd..a30221f2e69 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx @@ -351,10 +351,11 @@ export function Compositor(props: Omit ( void setScrollElement: (scrollElement: HTMLElement | null) => void + ariaDescribedBy: string | undefined } const renderDecorator: RenderDecoratorFunction = (props) => { @@ -84,6 +85,7 @@ export function Editor(props: EditorProps) { scrollElement, setPortalElement, setScrollElement, + ariaDescribedBy, } = props const {isTopLayer} = useLayer() const editableRef = useRef(null) @@ -148,6 +150,7 @@ export function Editor(props: EditorProps) { selection={initialSelection} style={noOutlineStyle} spellCheck={spellcheck} + aria-describedby={ariaDescribedBy} /> ), [ @@ -161,6 +164,7 @@ export function Editor(props: EditorProps) { renderPlaceholder, scrollSelectionIntoView, spellcheck, + ariaDescribedBy, ], ) diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx index a6c1723022e..2eeebc6f66b 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx @@ -300,6 +300,7 @@ export function ReferenceField(props: ReferenceFieldProps) { level={props.level} title={props.title} validation={props.validation} + inputId={props.inputId} > {isEditing ? ( {children} diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceItem.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceItem.tsx index 9cc4b9fc112..9ab53889c0a 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceItem.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceItem.tsx @@ -369,6 +369,7 @@ export function ReferenceItem {children} diff --git a/packages/sanity/src/core/form/members/array/items/ArrayOfObjectsItem.tsx b/packages/sanity/src/core/form/members/array/items/ArrayOfObjectsItem.tsx index 7423e4e0399..6f936fddfac 100644 --- a/packages/sanity/src/core/form/members/array/items/ArrayOfObjectsItem.tsx +++ b/packages/sanity/src/core/form/members/array/items/ArrayOfObjectsItem.tsx @@ -23,6 +23,7 @@ import {createProtoValue} from '../../../utils/createProtoValue' import {isEmptyItem} from '../../../store/utils/isEmptyItem' import {useResolveInitialValueForType} from '../../../../store' import {resolveInitialArrayValues} from '../../common/resolveInitialArrayValues' +import {createDescriptionId} from '../../common/createDescriptionId' /** * @@ -242,8 +243,9 @@ export function ArrayOfObjectsItem(props: MemberItemProps) { onFocus: handleFocus, id: member.item.id, ref: focusRef, + 'aria-describedby': createDescriptionId(member.item.id, member.item.schemaType.description), }), - [handleBlur, handleFocus, member.item.id], + [handleBlur, handleFocus, member.item.id, member.item.schemaType.description], ) const inputProps = useMemo((): Omit => { diff --git a/packages/sanity/src/core/form/members/array/items/ArrayOfPrimitivesItem.tsx b/packages/sanity/src/core/form/members/array/items/ArrayOfPrimitivesItem.tsx index 3e78e1e3caf..71bf0291d37 100644 --- a/packages/sanity/src/core/form/members/array/items/ArrayOfPrimitivesItem.tsx +++ b/packages/sanity/src/core/form/members/array/items/ArrayOfPrimitivesItem.tsx @@ -13,6 +13,7 @@ import { import {insert, PatchArg, PatchEvent, set, unset} from '../../../patch' import {useFormCallbacks} from '../../../studio/contexts/FormCallbacks' import {resolveNativeNumberInputValue} from '../../common/resolveNativeNumberInputValue' +import {createDescriptionId} from '../../common/createDescriptionId' /** * @@ -111,6 +112,7 @@ export function ArrayOfPrimitivesItem(props: PrimitiveMemberItemProps) { value: resolveNativeInputValue(member.item.schemaType, member.item.value, localValue), readOnly: Boolean(member.item.readOnly), placeholder: member.item.schemaType.placeholder, + 'aria-describedby': createDescriptionId(member.item.id, member.item.schemaType.description), }), [ handleBlur, diff --git a/packages/sanity/src/core/form/members/common/createDescriptionId.ts b/packages/sanity/src/core/form/members/common/createDescriptionId.ts new file mode 100644 index 00000000000..b0fc997bf8e --- /dev/null +++ b/packages/sanity/src/core/form/members/common/createDescriptionId.ts @@ -0,0 +1,14 @@ +import React from 'react' + +/** + * Creates a description id from a field id, for use with aria-describedby in the field, + * and added to the descriptive element id. + * @internal + */ +export function createDescriptionId( + id: string | undefined, + description: React.ReactNode | undefined, +): string | undefined { + if (!description || !id) return undefined + return `desc_${id}` +} diff --git a/packages/sanity/src/core/form/members/object/MemberFieldset.tsx b/packages/sanity/src/core/form/members/object/MemberFieldset.tsx index 93f997f6eb9..dbf3c6ff514 100644 --- a/packages/sanity/src/core/form/members/object/MemberFieldset.tsx +++ b/packages/sanity/src/core/form/members/object/MemberFieldset.tsx @@ -57,6 +57,7 @@ export const MemberFieldSet = memo(function MemberFieldSet(props: { onExpand={handleExpand} columns={member?.fieldSet?.columns} data-testid={`fieldset-${member.fieldSet.name}`} + inputId={member.fieldSet.name} > {member.fieldSet.members.map((fieldsetMember) => { if (fieldsetMember.kind === 'error') { diff --git a/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx b/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx index 93b8ba1a541..f7b98a0eeba 100644 --- a/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx +++ b/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx @@ -34,6 +34,7 @@ import {resolveInitialArrayValues} from '../../common/resolveInitialArrayValues' import {applyAll} from '../../../patch/applyPatch' import {useFormPublishedId} from '../../../useFormPublishedId' import {DocumentFieldActionNode} from '../../../../config' +import {createDescriptionId} from '../../common/createDescriptionId' /** * Responsible for creating inputProps and fieldProps to pass to ´renderInput´ and ´renderField´ for an array input @@ -298,8 +299,9 @@ export function ArrayOfObjectsField(props: { onFocus: handleFocus, id: member.field.id, ref: focusRef, + 'aria-describedby': createDescriptionId(member.field.id, member.field.schemaType.description), }), - [handleBlur, handleFocus, member.field.id], + [handleBlur, handleFocus, member.field.id, member.field.schemaType.description], ) const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) diff --git a/packages/sanity/src/core/form/members/object/fields/ArrayOfPrimitivesField.tsx b/packages/sanity/src/core/form/members/object/fields/ArrayOfPrimitivesField.tsx index af007a1fb88..ce263e112cd 100644 --- a/packages/sanity/src/core/form/members/object/fields/ArrayOfPrimitivesField.tsx +++ b/packages/sanity/src/core/form/members/object/fields/ArrayOfPrimitivesField.tsx @@ -36,6 +36,7 @@ import {readAsText} from '../../../studio/uploads/file/readAsText' import {accepts} from '../../../studio/uploads/accepts' import {applyAll} from '../../../patch/applyPatch' import {useFormBuilder} from '../../../useFormBuilder' +import {createDescriptionId} from '../../common/createDescriptionId' function move(arr: T[], from: number, to: number): T[] { const copy = arr.slice() @@ -301,8 +302,9 @@ export function ArrayOfPrimitivesField(props: { onFocus: handleFocus, id: member.field.id, ref: focusRef, + 'aria-describedby': createDescriptionId(member.field.id, member.field.schemaType.description), }), - [handleBlur, handleFocus, member.field.id], + [handleBlur, handleFocus, member.field.id, member.field.schemaType.description], ) const plainTextUploader = useMemo( diff --git a/packages/sanity/src/core/form/members/object/fields/ObjectField.tsx b/packages/sanity/src/core/form/members/object/fields/ObjectField.tsx index ff2d88a5191..1b2e1f10d5f 100644 --- a/packages/sanity/src/core/form/members/object/fields/ObjectField.tsx +++ b/packages/sanity/src/core/form/members/object/fields/ObjectField.tsx @@ -19,6 +19,7 @@ import {FormCallbacksProvider, useFormCallbacks} from '../../../studio/contexts/ import {createProtoValue} from '../../../utils/createProtoValue' import {applyAll} from '../../../patch/applyPatch' import {useFormBuilder} from '../../../useFormBuilder' +import {createDescriptionId} from '../../common/createDescriptionId' /** * Responsible for creating inputProps and fieldProps to pass to ´renderInput´ and ´renderField´ for an object input @@ -183,8 +184,9 @@ export const ObjectField = function ObjectField(props: { onFocus: handleFocus, id: member.field.id, ref: focusRef, + 'aria-describedby': createDescriptionId(member.field.id, member.field.schemaType.description), }), - [handleBlur, handleFocus, member.field.id], + [handleBlur, handleFocus, member.field.id, member.field.schemaType.description], ) const inputProps = useMemo((): Omit => { diff --git a/packages/sanity/src/core/form/members/object/fields/PrimitiveField.tsx b/packages/sanity/src/core/form/members/object/fields/PrimitiveField.tsx index 27be72b0ece..111e562a65e 100644 --- a/packages/sanity/src/core/form/members/object/fields/PrimitiveField.tsx +++ b/packages/sanity/src/core/form/members/object/fields/PrimitiveField.tsx @@ -11,6 +11,7 @@ import {FormPatch, PatchEvent, set, unset} from '../../../patch' import {useFormCallbacks} from '../../../studio/contexts/FormCallbacks' import {resolveNativeNumberInputValue} from '../../common/resolveNativeNumberInputValue' import {useFormBuilder} from '../../../useFormBuilder' +import {createDescriptionId} from '../../common/createDescriptionId' /** * Responsible for creating inputProps and fieldProps to pass to ´renderInput´ and ´renderField´ for a primitive field/input @@ -105,6 +106,7 @@ export function PrimitiveField(props: { value: resolveNativeNumberInputValue(member.field.schemaType, member.field.value, localValue), readOnly: Boolean(member.field.readOnly), placeholder: member.field.schemaType.placeholder, + 'aria-describedby': createDescriptionId(member.field.id, member.field.schemaType.description), }), [ handleBlur, diff --git a/packages/sanity/src/core/form/studio/FormBuilder.tsx b/packages/sanity/src/core/form/studio/FormBuilder.tsx index b9a1fdbb499..bc39e2db9af 100644 --- a/packages/sanity/src/core/form/studio/FormBuilder.tsx +++ b/packages/sanity/src/core/form/studio/FormBuilder.tsx @@ -177,7 +177,13 @@ function RootInput() { const rootInputProps: Omit = { focusPath, - elementProps: {ref: focusRef, id, onBlur: handleBlur, onFocus: handleFocus}, + elementProps: { + ref: focusRef, + id, + onBlur: handleBlur, + onFocus: handleFocus, + 'aria-describedby': undefined, // Root input should not have any aria-describedby + }, changed: members.some((m) => m.kind === 'field' && m.field.changed), focused, groups, diff --git a/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx b/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx index 3de5bdc1976..caf70a01612 100644 --- a/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx +++ b/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx @@ -105,6 +105,7 @@ function ObjectOrArrayField(field: ObjectFieldProps | ArrayFieldProps) { onExpand={field.onExpand} title={field.title} validation={field.validation} + inputId={field.inputId} > {field.children} @@ -152,6 +153,7 @@ function ImageOrFileField(field: ObjectFieldProps) { onExpand={field.onExpand} title={field.title} validation={field.validation} + inputId={field.inputId} > {field.children} diff --git a/packages/sanity/src/core/form/types/inputProps.ts b/packages/sanity/src/core/form/types/inputProps.ts index 41a5ca65d01..402e2c86f2a 100644 --- a/packages/sanity/src/core/form/types/inputProps.ts +++ b/packages/sanity/src/core/form/types/inputProps.ts @@ -392,6 +392,7 @@ export interface PrimitiveInputElementProps { onFocus: FocusEventHandler onBlur: FocusEventHandler ref: React.MutableRefObject + 'aria-describedby': string | undefined } /** @@ -402,6 +403,7 @@ export interface ComplexElementProps { onFocus: FocusEventHandler onBlur: FocusEventHandler ref: React.MutableRefObject + 'aria-describedby': string | undefined } /**