Skip to content

Commit

Permalink
feat(core): improved keyboard accessibility for editing with the Port…
Browse files Browse the repository at this point in the history
…able Text Input

This will allow a user to purely use the keyboard when editing links and inline objects,
which was not possible before.
  • Loading branch information
skogsmaskin committed Dec 12, 2024
1 parent 7520433 commit 55cbdaf
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 169 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export function Annotation(props: AnnotationProps): ReactNode {
)
}

export const DefaultAnnotationComponent = (props: BlockAnnotationProps) => {
export const DefaultAnnotationComponent = (props: BlockAnnotationProps): ReactElement => {
const {
__unstable_floatingBoundary: floatingBoundary,
__unstable_referenceBoundary: referenceBoundary,
Expand All @@ -259,8 +259,8 @@ export const DefaultAnnotationComponent = (props: BlockAnnotationProps) => {
onRemove,
open,
readOnly,
selected,
schemaType,
selected,
textElement,
validation,
} = props
Expand All @@ -271,6 +271,7 @@ export const DefaultAnnotationComponent = (props: BlockAnnotationProps) => {
const isReady = Boolean(children)

const {t} = useTranslation()

const toneKey = useMemo(() => {
if (hasError) {
return 'critical'
Expand Down Expand Up @@ -301,11 +302,11 @@ export const DefaultAnnotationComponent = (props: BlockAnnotationProps) => {
<AnnotationToolbarPopover
annotationOpen={open}
floatingBoundary={floatingBoundary}
onOpen={onOpen}
onRemove={onRemove}
onOpenAnnotation={onOpen}
onRemoveAnnotation={onRemove}
referenceBoundary={referenceBoundary}
referenceElement={referenceElement}
selected={selected}
annotationTextSelected={selected}
title={
schemaType.i18nTitleKey
? t(schemaType.i18nTitleKey)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {PortableTextEditor, usePortableTextEditor} from '@portabletext/editor'
import {EditIcon, TrashIcon} from '@sanity/icons'
import {Box, Flex, Text, useGlobalKeyDown, useTheme} from '@sanity/ui'
import {startTransition, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'

import {Button, Popover, type PopoverProps} from '../../../../../ui-components'
import {useTranslation} from '../../../../i18n'
Expand All @@ -9,47 +10,36 @@ const POPOVER_FALLBACK_PLACEMENTS: PopoverProps['fallbackPlacements'] = ['top',

interface AnnotationToolbarPopoverProps {
annotationOpen: boolean
annotationTextSelected: boolean
floatingBoundary: HTMLElement | null
onOpen: () => void
onRemove: () => void
onOpenAnnotation: () => void
onRemoveAnnotation: () => void
referenceBoundary: HTMLElement | null
referenceElement: HTMLElement | null
selected: boolean
title: string
}

export function AnnotationToolbarPopover(props: AnnotationToolbarPopoverProps) {
export function AnnotationToolbarPopover(props: AnnotationToolbarPopoverProps): ReactNode {
const {
annotationOpen,
annotationTextSelected,
floatingBoundary,
onOpen,
onRemove,
onOpenAnnotation,
onRemoveAnnotation,
referenceBoundary,
referenceElement,
selected,
title,
} = props
const [renderPopover, setRenderPopover] = useState<boolean>(false)
const [popoverOpen, setPopoverOpen] = useState<boolean>(false)
const [cursorRect, setCursorRect] = useState<DOMRect | null>(null)
const rangeRef = useRef<Range | null>(null)
const {sanity} = useTheme()
const {t} = useTranslation()
const popoverRef = useRef<HTMLDivElement | null>(null)
const editButtonRef = useRef<HTMLButtonElement | null>(null)
const deleteButtonRef = useRef<HTMLButtonElement | null>(null)
const focusTrappedRef = useRef<HTMLButtonElement | null>(null)
const popoverScheme = sanity.color.dark ? 'light' : 'dark'

//Add separate handler for popover state
//to prevent the popover from jumping when opening
const handleOpenPopover = useCallback((open: boolean) => {
setRenderPopover(open)
if (open) {
startTransition(() => {
setPopoverOpen(open)
})
} else {
setPopoverOpen(open)
}
}, [])
const editor = usePortableTextEditor()

// This is a "virtual element" (supported by Popper.js)
const cursorElement = useMemo(() => {
Expand All @@ -63,25 +53,52 @@ export function AnnotationToolbarPopover(props: AnnotationToolbarPopoverProps) {
}
}, [cursorRect]) as HTMLElement

const handleClosePopover = useCallback(() => {
PortableTextEditor.focus(editor)
setPopoverOpen(false)
focusTrappedRef.current = null
}, [editor])

// Tab to edit button on tab
// Close floating toolbar on Escape
useGlobalKeyDown(
useCallback(
(event) => {
if (!popoverOpen) {
return
}
if (event.key === 'Tab') {
if (
annotationTextSelected &&
event.target instanceof HTMLElement &&
event.target.contentEditable &&
focusTrappedRef.current === null
) {
event.preventDefault()
editButtonRef.current?.focus()
focusTrappedRef.current = editButtonRef.current
return
}
if (event.target === deleteButtonRef.current) {
event.preventDefault()
event.stopPropagation()
focusTrappedRef.current = null
PortableTextEditor.focus(editor)
return
}
}
if (event.key === 'Escape') {
handleOpenPopover(false)
handleClosePopover()
}
},
[handleOpenPopover, popoverOpen],
[editor, handleClosePopover, popoverOpen, annotationTextSelected],
),
)

// Open popover when selection is within the annotation text
const handleSelectionChange = useCallback(() => {
if (annotationOpen) {
handleOpenPopover(false)
setPopoverOpen(false)
setCursorRect(null)
return
}
Expand All @@ -90,20 +107,21 @@ export function AnnotationToolbarPopover(props: AnnotationToolbarPopoverProps) {

if (!sel || sel.rangeCount === 0) return

focusTrappedRef.current = null
const range = sel.getRangeAt(0)
const isWithinRoot = referenceElement?.contains(range.commonAncestorContainer)

if (!isWithinRoot) {
handleOpenPopover(false)
setPopoverOpen(false)
setCursorRect(null)
return
}
const rect = range?.getBoundingClientRect()
if (rect) {
setCursorRect(rect)
handleOpenPopover(true)
setPopoverOpen(true)
}
}, [annotationOpen, referenceElement, handleOpenPopover])
}, [annotationOpen, referenceElement, setPopoverOpen])

// Detect selection changes
useEffect(() => {
Expand All @@ -114,24 +132,14 @@ export function AnnotationToolbarPopover(props: AnnotationToolbarPopoverProps) {
}, [handleSelectionChange])

const handleEditButtonClicked = useCallback(() => {
handleOpenPopover(false)
onOpen()
}, [onOpen, handleOpenPopover])

// Open the popover when closing the annotation dialog
useEffect(() => {
if (!annotationOpen && selected && cursorRect) {
handleOpenPopover(true)
}
if (annotationOpen) {
handleOpenPopover(false)
}
}, [annotationOpen, selected, cursorRect, handleOpenPopover])
setPopoverOpen(false)
onOpenAnnotation()
}, [onOpenAnnotation])

const handleRemoveButtonClicked = useCallback(() => {
handleOpenPopover(false)
onRemove()
}, [onRemove, handleOpenPopover])
setPopoverOpen(false)
onRemoveAnnotation()
}, [onRemoveAnnotation])

const handleScroll = useCallback(() => {
if (rangeRef.current) {
Expand All @@ -147,26 +155,17 @@ export function AnnotationToolbarPopover(props: AnnotationToolbarPopoverProps) {
}, [popoverOpen])

useEffect(() => {
//Attach and detach scroll event listener for popover to follow the current reference boundary
if (popoverOpen && referenceBoundary) {
referenceBoundary.addEventListener('scroll', handleScroll)
return () => referenceBoundary.removeEventListener('scroll', handleScroll)
}

if (!popoverOpen) {
return undefined
// Listen for scroll events on the floating boundary and the reference boundary
// and move the popover accordingly
if (popoverOpen) {
floatingBoundary?.addEventListener('scroll', handleScroll)
referenceBoundary?.addEventListener('scroll', handleScroll)
}

referenceBoundary?.addEventListener('scroll', handleScroll)

return () => {
floatingBoundary?.removeEventListener('scroll', handleScroll)
referenceBoundary?.removeEventListener('scroll', handleScroll)
}
}, [popoverOpen, referenceBoundary, handleScroll])

if (!renderPopover) {
return null
}
}, [popoverOpen, referenceBoundary, floatingBoundary, handleScroll])

return (
<Popover
Expand All @@ -187,6 +186,7 @@ export function AnnotationToolbarPopover(props: AnnotationToolbarPopoverProps) {
icon={EditIcon}
mode="bleed"
onClick={handleEditButtonClicked}
ref={editButtonRef}
tabIndex={0}
tooltipProps={null}
/>
Expand All @@ -196,6 +196,7 @@ export function AnnotationToolbarPopover(props: AnnotationToolbarPopoverProps) {
icon={TrashIcon}
mode="bleed"
onClick={handleRemoveButtonClicked}
ref={deleteButtonRef}
tabIndex={0}
tone="critical"
tooltipProps={null}
Expand All @@ -207,7 +208,6 @@ export function AnnotationToolbarPopover(props: AnnotationToolbarPopoverProps) {
placement="top"
portal
preventOverflow
ref={popoverRef}
referenceBoundary={referenceBoundary}
referenceElement={cursorElement}
scheme={popoverScheme}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ export const DefaultBlockObjectComponent = (props: BlockProps) => {
floatingBoundary={__unstable_floatingBoundary}
defaultType="dialog"
onClose={onClose}
autoFocus={focused}
autoFocus
schemaType={schemaType}
referenceBoundary={__unstable_referenceBoundary}
referenceElement={__unstable_referenceElement}
Expand Down
Loading

0 comments on commit 55cbdaf

Please sign in to comment.