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

fix(core/form/inputs): fix issue with tabbing to popover toolbar buttons in PT-input #5057

Open
wants to merge 2 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,25 @@ test.describe('Portable Text Input', () => {
// Expect the editor to have focus after closing the popover
await expect($pte).toBeFocused()

const $toolbarPopover = page.getByTestId('annotation-toolbar-popover')

// Assertion: the annotation toolbar popover should be visible
await expect(page.getByTestId('annotation-toolbar-popover')).toBeVisible()
await expect($toolbarPopover).toBeVisible()

// Assertion: tab works to get to the toolbar popover buttons
await page.keyboard.press('Tab')
await expect(page.getByTestId('edit-annotation-button')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByTestId('remove-annotation-button')).toBeFocused()
await page.keyboard.press('Escape')
await expect($pte).toBeFocused()
await expect($toolbarPopover).toBeVisible()
await page.waitForTimeout(100)
await page.keyboard.press('Escape')
await page.waitForTimeout(100)
// Assertion: escape closes the toolbar popover
await expect($toolbarPopover).not.toBeVisible()
})

test('Can create, and then open the existing annotation again for editing', async ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ test.describe('Portable Text Input', () => {
const $portableTextInput = component.getByTestId('field-body')
const $pteTextbox = $portableTextInput.getByRole('textbox')
await expect($pteTextbox).not.toBeFocused()
await page.keyboard.press('Tab+Tab')
const blockObjectInput = page.getByTestId('objectBlockInputField').getByRole('textbox')
await expect(blockObjectInput).toBeFocused()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,32 @@ test.describe('Portable Text Input', () => {
await expect($pte.getByText('Custom preview block:')).toBeVisible()
})

test('Inline object toolbars works as expected', async ({mount, page}) => {
const {getFocusedPortableTextEditor} = testHelpers({page})
await mount(<ObjectBlockStory />)
const $pte = await getFocusedPortableTextEditor('field-body')
await page.getByRole('button', {name: 'Insert Inline Object (inline)'}).click()
// Assertion: the annotation toolbar popover should not be visible
await expect(page.getByTestId('inline-object-toolbar-popover')).not.toBeVisible()
const $locatorDialog = page.getByTestId('popover-edit-dialog')
// Assertion: Object edit dialog should be visible
await expect($locatorDialog).toBeVisible()
await page.keyboard.press('Escape')
// Assertion: the annotation toolbar popover should be visible
await expect(page.getByTestId('inline-object-toolbar-popover')).toBeVisible()
// Assertion: tab works to get to the toolbar popover buttons
await page.keyboard.press('Tab')
await expect(page.getByTestId('edit-inline-object-button')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByTestId('remove-inline-object-button')).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.getByTestId('edit-inline-object-button')).toBeVisible()
await expect($pte).toBeFocused()
await page.keyboard.press('Escape')
// Assertion: escape closes the toolbar popover
await expect(page.getByTestId('inline-object-toolbar-popover')).not.toBeVisible()
})

test('Double-clicking opens a block', async ({mount, page}) => {
const {getFocusedPortableTextEditor} = testHelpers({page})
await mount(<ObjectBlockStory />)
Expand Down
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
Loading