Skip to content

Commit

Permalink
feat(WCAG): Associate field descriptions to inputs (#4896)
Browse files Browse the repository at this point in the history
* 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 <per.kristian.nordnes@gmail.com>
  • Loading branch information
pedrobonamin and skogsmaskin committed Sep 13, 2023
1 parent a94680b commit e7a8e32
Show file tree
Hide file tree
Showing 20 changed files with 132 additions and 95 deletions.
68 changes: 28 additions & 40 deletions packages/@sanity/portable-text-editor/src/editor/Editable.tsx
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}
/>
)
})
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
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
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
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
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
4 changes: 4 additions & 0 deletions packages/sanity/src/core/form/inputs/PortableText/Editor.tsx
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
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
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
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
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
@@ -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}`
}
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
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

2 comments on commit e7a8e32

@vercel
Copy link

@vercel vercel bot commented on e7a8e32 Sep 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

performance-studio – ./

performance-studio-git-next.sanity.build
performance-studio.sanity.build

@vercel
Copy link

@vercel vercel bot commented on e7a8e32 Sep 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

test-studio – ./

test-studio.sanity.build
test-studio-git-next.sanity.build

Please sign in to comment.