Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(WCAG): Associate field descriptions to inputs #4896

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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
68 changes: 28 additions & 40 deletions packages/@sanity/portable-text-editor/src/editor/Editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ const EMPTY_DECORATORS: BaseRange[] = []
/**
* @public
*/
export type PortableTextEditableProps = {
export type PortableTextEditableProps = Omit<
React.TextareaHTMLAttributes<HTMLDivElement>,
'onPaste' | 'onCopy'
> & {
hotkeys?: HotkeyOptions
onBeforeInput?: OnBeforeInputFn
onPaste?: OnPasteFn
Expand Down Expand Up @@ -387,48 +390,33 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
return EMPTY_DECORATORS
}, [schemaTypes, slateEditor])

// The editor
const slateEditable = useMemo(
() => (
<SlateEditable
autoFocus={false}
className="pt-editable"
decorate={decorate}
onBlur={handleOnBlur}
onCopy={handleCopy}
onDOMBeforeInput={handleOnBeforeInput}
onFocus={handleOnFocus}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
readOnly={readOnly}
renderElement={renderElement}
renderLeaf={renderLeaf}
style={props.style}
scrollSelectionIntoView={scrollSelectionIntoViewToSlate}
/>
),
[
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 (
<div ref={ref} {...restProps}>
{hasInvalidValue ? null : slateEditable}
</div>
return hasInvalidValue ? null : (
<SlateEditable
{...restProps}
autoFocus={false}
className={restProps.className || 'pt-editable'}
decorate={decorate}
onBlur={handleOnBlur}
onCopy={handleCopy}
onDOMBeforeInput={handleOnBeforeInput}
onFocus={handleOnFocus}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
readOnly={readOnly}
// We have implemented our own placeholder logic with decorations.
// This 'renderPlaceholder' should not be used.
renderPlaceholder={undefined}
renderElement={renderElement}
renderLeaf={renderLeaf}
scrollSelectionIntoView={scrollSelectionIntoViewToSlate}
/>
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -35,57 +35,56 @@ describe('initialization', () => {
expect(onChange).toHaveBeenCalledWith({type: 'ready'})
expect(onChange).toHaveBeenCalledWith({type: 'value', value: undefined})
expect(container).toMatchInlineSnapshot(`
<div>
<div
aria-describedby="desc_foo"
aria-multiline="true"
autocapitalize="false"
autocorrect="false"
class="pt-editable"
contenteditable="true"
data-slate-editor="true"
data-slate-node="value"
role="textbox"
spellcheck="false"
style="position: relative; white-space: pre-wrap; word-wrap: break-word;"
zindex="-1"
>
<div
class="pt-block pt-text-block pt-text-block-style-normal"
data-slate-node="element"
>
<div
draggable="false"
>
<div>
<div>
<div
aria-multiline="true"
autocapitalize="false"
autocorrect="false"
class="pt-editable"
contenteditable="true"
data-slate-editor="true"
data-slate-node="value"
role="textbox"
spellcheck="false"
style="position: relative; white-space: pre-wrap; word-wrap: break-word;"
zindex="-1"
<span
data-slate-node="text"
>
<span
contenteditable="false"
style="opacity: 0.5; position: absolute; user-select: none; pointer-events: none;"
>
<div
class="pt-block pt-text-block pt-text-block-style-normal"
data-slate-node="element"
Jot something down here
</span>
<span
data-slate-leaf="true"
>
<span
data-slate-length="0"
data-slate-zero-width="n"
>
<div
draggable="false"
>
<div>
<span
data-slate-node="text"
>
<span
contenteditable="false"
style="opacity: 0.5; position: absolute; user-select: none; pointer-events: none;"
>
Jot something down here
</span>
<span
data-slate-leaf="true"
>
<span
data-slate-length="0"
data-slate-zero-width="n"
>

<br />
</span>
</span>
</span>
</div>
</div>
</div>
</div>
</div>

<br />
</span>
</span>
</span>
</div>
`)
</div>
</div>
</div>
</div>
`)
})
})
it('takes value from props', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const PortableTextEditorTester = forwardRef(function PortableTextEditorTe
<PortableTextEditable
selection={props.selection || undefined}
renderPlaceholder={props.renderPlaceholder}
aria-describedby="desc_foo"
/>
</PortableTextEditor>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -45,7 +46,7 @@ export const FormFieldHeaderText = memo(function FormFieldHeaderText(
</Flex>

{description && (
<Text muted size={1}>
<Text muted size={1} id={createDescriptionId(inputId, description)}>
{description}
</Text>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -43,6 +44,7 @@ export interface FormFieldSetProps {
* @beta
*/
validation?: FormNodeValidation[]
inputId: string
}

function getChildren(children: React.ReactNode | (() => React.ReactNode)): React.ReactNode {
Expand Down Expand Up @@ -109,6 +111,7 @@ export const FormFieldSet = forwardRef(function FormFieldSet(
tabIndex,
title,
validation = EMPTY_ARRAY,
inputId,
...restProps
} = props

Expand Down Expand Up @@ -170,7 +173,7 @@ export const FormFieldSet = forwardRef(function FormFieldSet(
</Flex>

{description && (
<Text muted size={1}>
<Text muted size={1} id={createDescriptionId(inputId, description)}>
{description}
</Text>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,11 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
renderPreview,
],
)

const ariaDescribedBy = props.elementProps['aria-describedby']
const editorNode = useMemo(
() => (
<Editor
ariaDescribedBy={ariaDescribedBy}
hasFocus={hasFocus}
hotkeys={editorHotkeys}
isActive={isActive}
Expand Down Expand Up @@ -390,6 +391,7 @@ export function Compositor(props: Omit<InputProps, 'schemaType' | 'arrayFunction
onPaste,
path,
readOnly,
ariaDescribedBy,
],
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ interface EditorProps {
scrollElement: HTMLElement | null
setPortalElement?: (portalElement: HTMLDivElement | null) => void
setScrollElement: (scrollElement: HTMLElement | null) => void
ariaDescribedBy: string | undefined
}

const renderDecorator: RenderDecoratorFunction = (props) => {
Expand Down Expand Up @@ -84,6 +85,7 @@ export function Editor(props: EditorProps) {
scrollElement,
setPortalElement,
setScrollElement,
ariaDescribedBy,
} = props
const {isTopLayer} = useLayer()
const editableRef = useRef<HTMLDivElement | null>(null)
Expand Down Expand Up @@ -148,6 +150,7 @@ export function Editor(props: EditorProps) {
selection={initialSelection}
style={noOutlineStyle}
spellCheck={spellcheck}
aria-describedby={ariaDescribedBy}
/>
),
[
Expand All @@ -161,6 +164,7 @@ export function Editor(props: EditorProps) {
renderPlaceholder,
scrollSelectionIntoView,
spellcheck,
ariaDescribedBy,
],
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ export function ReferenceField(props: ReferenceFieldProps) {
level={props.level}
title={props.title}
validation={props.validation}
inputId={props.inputId}
>
{isEditing ? (
<Box>{children}</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ export function ReferenceItem<Item extends ReferenceItemValue = ReferenceItemVal
description={schemaType.description}
__unstable_presence={presence}
validation={validation}
inputId={inputId}
>
{children}
</FormFieldSet>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
*
Expand Down Expand Up @@ -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<ObjectInputProps, 'renderDefault'> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
*
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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}`
}
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down