diff --git a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCard.tsx b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCard.tsx index 5d3ba0b5625f..dcdbf363df4d 100644 --- a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCard.tsx +++ b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCard.tsx @@ -2,6 +2,7 @@ import { forwardRef, memo } from 'react'; import type { WorkPackage } from '../../openProjectTypes'; import type { BlockWpSize } from '../WorkPackage/types'; import type { TapActivationProps } from '../../utils/tapActivation'; +import type { MenuButtonProps } from '../../utils/a11y'; import { BlockCardM, BlockCardL, BlockCardXL } from './BlockCards'; export interface BlockCardProps { @@ -10,14 +11,15 @@ export interface BlockCardProps { inDropdown?:boolean; linkTitle?:boolean; onActivation?:TapActivationProps; + menuButton?:MenuButtonProps; } // Memoized: rendered inside popovers that re-render on hover/selection, while // the work package reference stays stable (cached by useWorkPackage). export const BlockCard = memo( forwardRef( - ({ workPackage, size = 'm', inDropdown, linkTitle, onActivation }, ref) => { - const shared = { workPackage, inDropdown, linkTitle, onActivation, cardRef: ref }; + ({ workPackage, size = 'm', inDropdown, linkTitle, onActivation, menuButton }, ref) => { + const shared = { workPackage, inDropdown, linkTitle, onActivation, menuButton, cardRef: ref }; if (size === 'xl') return ; if (size === 'l') return ; diff --git a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCards.tsx b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCards.tsx index 8cb7e87a7419..3125b2c53993 100644 --- a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCards.tsx +++ b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockCards.tsx @@ -19,6 +19,7 @@ import { } from '../../services/colors'; import { formatWorkPackageId } from '../../utils/id'; import type { TapActivationProps } from '../../utils/tapActivation'; +import type { MenuButtonProps } from '../../utils/a11y'; const DESCRIPTION_MAX_CHARS = 300; @@ -30,6 +31,7 @@ export interface BlockCardSharedProps { inDropdown?:boolean; linkTitle?:boolean; onActivation?:TapActivationProps; + menuButton?:MenuButtonProps; } function buildTitle(workPackage:WorkPackage, linkTitle:boolean) { @@ -98,13 +100,14 @@ const DescriptionSnippet = styled.p` const CardMeta = ({ workPackage, withRelations = false, -}:{ workPackage:WorkPackage; withRelations?:boolean }) => ( + menuButton, +}:{ workPackage:WorkPackage; withRelations?:boolean; menuButton?:MenuButtonProps }) => ( <> {workPackage._links?.type?.title} {WRAP_OPPORTUNITY} - {formatWorkPackageId(workPackage.displayId)} + {formatWorkPackageId(workPackage.displayId)} {WRAP_OPPORTUNITY} }) => ( - + {buildTitle(workPackage, linkTitle)} @@ -160,6 +163,7 @@ export const BlockCardL = ({ inDropdown = false, linkTitle = false, onActivation, + menuButton, cardRef, }:BlockCardSharedProps & { cardRef?:React.Ref }) => ( - + {buildTitle(workPackage, linkTitle)} @@ -184,6 +187,7 @@ export const BlockCardXL = ({ inDropdown = false, linkTitle = false, onActivation, + menuButton, cardRef, }:BlockCardSharedProps & { cardRef?:React.Ref }) => { const rawDescription = workPackage.description?.raw; @@ -200,12 +204,11 @@ export const BlockCardXL = ({ className="op-bn-work-package op-bn-work-package--xl" $inDropdown={inDropdown} {...onActivation} - role={onActivation ? 'button' : undefined} data-testid="block-card" style={onActivation ? { cursor: 'pointer' } : undefined} > - + {buildTitle(workPackage, linkTitle)} {snippetText && ( diff --git a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx index 366fb3e31f2c..ccd0aec258df 100644 --- a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx +++ b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/BlockWorkPackage.tsx @@ -20,6 +20,12 @@ import { hideSafariPhantomSelection, selectBlockNode } from '../../utils/selecti import { pendingBlockRegistry } from './pendingBlockRegistry'; import { useSuppressFormattingToolbar } from '../../hooks/useSuppressFormattingToolbar'; import { useTapActivation } from '../../utils/tapActivation'; +import { useOptionsHost } from '../../utils/optionsHost'; +import { isKeyboardClick, menuButtonProps } from '../../utils/a11y'; +import { useOptionsMenu } from '../../hooks/useOptionsMenu'; +import { useSelectionAnnouncement } from '../../hooks/useSelectionAnnouncement'; +import { describeLinkedWorkPackage, unavailableKindOf, workPackageLabel } from '../WorkPackage/description'; +import { useTranslation } from 'react-i18next'; const Block = styled.div.attrs({ className: 'op-bn-extensions', 'data-testid': 'block-wp-wrapper' })<{ $pending?:boolean; $selected?:boolean }>` ${defaultWpVariables} @@ -55,6 +61,7 @@ export const BlockWorkPackageComponent = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any editor:BlockNoteEditor; }) => { + const { t } = useTranslation(); const cardRef = useRef(null); const [blockEl, setBlockEl] = useState(null); // Fetch and cache colors. @@ -67,7 +74,7 @@ export const BlockWorkPackageComponent = ({ // and the outline never appears. So we set the outline ourselves. const selectedBlocks = useSelectedBlocks(editor); const isBlockSelected = selectedBlocks.some((b) => b.id === block.id); - const [isOptionsOpen, setIsOptionsOpen] = useState(false); + const options = useOptionsMenu(); useEffect(() => { if (!isBlockSelected) return; @@ -75,13 +82,13 @@ export const BlockWorkPackageComponent = ({ return editor.onSelectionChange(() => hideSafariPhantomSelection(editor)); }, [isBlockSelected, editor]); - useSuppressFormattingToolbar(editor, isOptionsOpen); + useSuppressFormattingToolbar(editor, options.isOpen); const tapProps = useTapActivation(); const toggleOptions = (event?:React.MouseEvent) => { event?.stopPropagation(); selectBlockNode(editor, block.id); - setIsOptionsOpen((prev) => !prev); + options.toggle(isKeyboardClick(event)); }; const workPackageResult = useWorkPackage(block.props.wpid); @@ -100,6 +107,17 @@ export const BlockWorkPackageComponent = ({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const displayId = block.props.displayId || String(block.props.wpid); + const unavailableKind = unavailableKindOf(workPackageResult); + const hasOptions = Boolean(block.props.wpid && !workPackageResult.loading && (selectedWorkPackage ?? unavailableKind)); + + const optionsHostRef = useOptionsHost(() => { + if (hasOptions) options.open(true); + }); + + useSelectionAnnouncement(cardRef, editor, describeLinkedWorkPackage(t, displayId, selectedWorkPackage, unavailableKind)); + + const menuButton = menuButtonProps(workPackageLabel(t, displayId), options.isOpen); + // Read once into state: the registry entry is dropped on unmount, which // StrictMode simulates, and a remount must not lose a filled form. const [pendingMode, setPendingMode] = useState(() => pendingBlockRegistry.mode(block.id)); @@ -141,12 +159,13 @@ export const BlockWorkPackageComponent = ({ // Touch is listened for in its own right: a tap another element answers // never becomes a mousedown. + const { isOpen: isOptionsOpen, close: closeOptions } = options; useEffect(() => { if (!isOptionsOpen) return; const handlePressOutside = (e:Event) => { const path = e.composedPath(); if (cardRef.current && !path.includes(cardRef.current)) { - setIsOptionsOpen(false); + closeOptions(); } }; document.addEventListener('mousedown', handlePressOutside); @@ -155,7 +174,7 @@ export const BlockWorkPackageComponent = ({ document.removeEventListener('mousedown', handlePressOutside); document.removeEventListener('touchstart', handlePressOutside); }; - }, [isOptionsOpen]); + }, [isOptionsOpen, closeOptions]); const handleConvertToInline = (size:InlineWpSize) => { if (!block.props.wpid) return; @@ -174,6 +193,7 @@ export const BlockWorkPackageComponent = ({ const trackBlockElement = (node:HTMLDivElement | null) => { if (pendingMode !== undefined || node === null) setBlockEl(node); + optionsHostRef(node); }; const optionsPopover = ( @@ -184,7 +204,9 @@ export const BlockWorkPackageComponent = ({ currentBlockSize={cardSize} // eslint-disable-next-line react-hooks/refs anchorEl={cardRef.current} - onClose={() => setIsOptionsOpen(false)} + autoFocus={options.takesFocus} + onClose={options.close} + onRestoreFocus={() => editor.focus()} onConvertToInline={handleConvertToInline} onConvertToBlock={handleResizeBlock} onResizeBlock={handleResizeBlock} @@ -215,7 +237,6 @@ export const BlockWorkPackageComponent = ({ {!workPackageResult.loading && (workPackageResult.error ?? workPackageResult.unauthorized) && ( {workPackageResult.error ? ( @@ -224,6 +245,7 @@ export const BlockWorkPackageComponent = ({ headerKey="unavailableWorkPackage.error.header" messageKey="unavailableWorkPackage.error.message" displayId={displayId} + menuButton={menuButton} /> ) : ( )} - {isOptionsOpen && optionsPopover} + {options.isOpen && optionsPopover} )} {!workPackageResult.loading && @@ -248,8 +271,9 @@ export const BlockWorkPackageComponent = ({ size={cardSize} linkTitle onActivation={tapProps(toggleOptions)} + menuButton={menuButton} /> - {isOptionsOpen && optionsPopover} + {options.isOpen && optionsPopover} )} diff --git a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/spec.tsx b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/spec.tsx index 3feff0331862..ffdf0bf9e375 100644 --- a/extensions/blocknote-extensions/lib/components/BlockWorkPackage/spec.tsx +++ b/extensions/blocknote-extensions/lib/components/BlockWorkPackage/spec.tsx @@ -6,6 +6,7 @@ import { parseWorkPackageBlockExternalHTML, } from './externalHtml'; import { PasteWorkPackageLinkExtension } from '../../plugins/pasteWorkPackageLinkExtension'; +import { WorkPackageAccessibilityExtension } from '../../plugins/workPackageAccessibilityExtension'; export { blockConfig }; @@ -33,5 +34,6 @@ export const openProjectWorkPackageBlockSpec = createReactBlockSpec( [ PasteWorkPackageLinkExtension, + WorkPackageAccessibilityExtension(), ] ); diff --git a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineChips.tsx b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineChips.tsx index adfbd026ffe6..cfbd090b40fc 100644 --- a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineChips.tsx +++ b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineChips.tsx @@ -17,7 +17,7 @@ import { } from '../WorkPackage/atoms'; import { formatWorkPackageId } from '../../utils/id'; import { PreviewIndicator } from './PreviewIndicator'; -import { buttonActivationProps } from '../../utils/a11y'; +import type { MenuButtonProps } from '../../utils/a11y'; import type { WorkPackagePreview } from '../../hooks/useWorkPackagePreview'; const resolvedDisplayId = (wp:WorkPackage) => wp.displayId ?? String(wp.id); @@ -28,18 +28,15 @@ const titleLinkProps = (wp:WorkPackage) => ({ $compact: true, }); -// XXS — "#ID" -export const WpChipXXS = ({ wp, preview, actionLabel }:{ +interface WpChipProps { wp:WorkPackage; - preview:WorkPackagePreview; - actionLabel:string; -}) => ( + menuButton?:MenuButtonProps; +} + +// XXS — "#ID" +export const WpChipXXS = ({ wp, menuButton, preview }:WpChipProps & { preview:WorkPackagePreview }) => ( - + {formatWorkPackageId(resolvedDisplayId(wp))} @@ -47,9 +44,9 @@ export const WpChipXXS = ({ wp, preview, actionLabel }:{ ); // XS — "#ID TYPE subject" -export const WpChipXS = ({ wp }:{ wp:WorkPackage }) => ( +export const WpChipXS = ({ wp, menuButton }:WpChipProps) => ( - {formatWorkPackageId(resolvedDisplayId(wp))} + {formatWorkPackageId(resolvedDisplayId(wp))} {WRAP_OPPORTUNITY} {wp._links?.type?.title && ( @@ -64,9 +61,9 @@ export const WpChipXS = ({ wp }:{ wp:WorkPackage }) => ( ); // S — "#ID TYPE [Status] subject" -export const WpChipS = ({ wp }:{ wp:WorkPackage }) => ( +export const WpChipS = ({ wp, menuButton }:WpChipProps) => ( - {formatWorkPackageId(resolvedDisplayId(wp))} + {formatWorkPackageId(resolvedDisplayId(wp))} {WRAP_OPPORTUNITY} {wp._links?.type?.title && ( diff --git a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx index ef7824be0b70..fb7416833bbf 100644 --- a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx +++ b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx @@ -20,10 +20,14 @@ import { } from '../../utils/inlineChipActions'; import { BlockCard } from '../BlockWorkPackage/BlockCard'; import { useTranslation } from 'react-i18next'; -import { formatWorkPackageId } from '../../utils/id'; import { useIsNodeInSelection } from '../../hooks/useIsNodeInSelection'; import { useSuppressFormattingToolbar } from '../../hooks/useSuppressFormattingToolbar'; +import { useOptionsMenu } from '../../hooks/useOptionsMenu'; +import { useSelectionAnnouncement } from '../../hooks/useSelectionAnnouncement'; import { useTapActivation } from '../../utils/tapActivation'; +import { useOptionsHost } from '../../utils/optionsHost'; +import { isKeyboardClick, menuButtonProps } from '../../utils/a11y'; +import { describeLinkedWorkPackage, unavailableKindOf, workPackageLabel } from '../WorkPackage/description'; import type { BlockNoteEditor } from '@blocknote/core'; export interface InlineWorkPackageChipProps { @@ -57,25 +61,37 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat updateInlineContent({ type: 'openProjectWorkPackageInline', props: { ...inlineContent.props, displayId: wp.displayId } }); }, [wp?.displayId]); // eslint-disable-line react-hooks/exhaustive-deps - const [isSelected, setIsSelected] = useState(false); + const options = useOptionsMenu(); const chipRef = useRef(null); const [chipEl, setChipEl] = useState(null); const preview = useWorkPackagePreview({ enabled: size === 'xxs', - suppressed: isSelected, + suppressed: options.isOpen, // The indicator shows the preview instead of the options menu, not over it. - onOpen: () => setIsSelected(false), + onOpen: options.close, }); const { previewOpen, closePreview, triggerProps, cardProps } = preview; const isEditorSelected = useIsNodeInSelection(chipRef, editor); - useSuppressFormattingToolbar(editor, isSelected || previewOpen); + useSuppressFormattingToolbar(editor, options.isOpen || previewOpen); + + const unavailableKind = unavailableKindOf({ unauthorized, error }); + const hasOptions = Boolean(wpid && (wp ?? unavailableKind)); + + const optionsHostRef = useOptionsHost(() => { + if (!hasOptions) return; + closePreview(); + options.open(true); + }); + + useSelectionAnnouncement(chipRef, editor, describeLinkedWorkPackage(t, displayId, wp, unavailableKind)); const setRef = (node:HTMLElement | null) => { chipRef.current = node; if (pendingCallbacks || node === null) setChipEl(node); + optionsHostRef(node); contentRef(node); }; @@ -86,9 +102,9 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat editor.getExtension('formattingToolbar')?.store?.setState(false); }; - const toggleOptions = () => { + const toggleOptions = (takesFocus:boolean) => { closePreview(); - setIsSelected((prev) => !prev); + options.toggle(takesFocus); selectWorkPackageNode(); }; @@ -98,15 +114,16 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat const onChipActivation = tapProps((event) => { event?.preventDefault(); event?.stopPropagation(); - toggleOptions(); + toggleOptions(isKeyboardClick(event)); }); // Close the options popover and the preview when the user taps outside the chip + const { isOpen: isOptionsOpen, close: closeOptions } = options; useEffect(() => { - if (!isSelected && !previewOpen) return; + if (!isOptionsOpen && !previewOpen) return; const onPressOutside = (e:Event) => { if (chipRef.current && !chipRef.current.contains(e.target as Node)) { - setIsSelected(false); + closeOptions(); closePreview(); } }; @@ -117,7 +134,7 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat document.removeEventListener('mousedown', onPressOutside); document.removeEventListener('touchstart', onPressOutside); }; - }, [isSelected, previewOpen, closePreview]); + }, [isOptionsOpen, previewOpen, closeOptions, closePreview]); const optionsPopover = ( setIsSelected(false)} + autoFocus={options.takesFocus} + onClose={options.close} + onRestoreFocus={() => editor?.focus()} onResize={(newSize) => { updateInlineContent?.({ type: 'openProjectWorkPackageInline', props: { ...inlineContent.props, size: newSize } }); }} @@ -182,23 +201,20 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat // Resolved if (wpid && wp) { // Hidden while the options menu is open so the two popovers never stack. - const showPreview = size === 'xxs' && previewOpen && !isSelected; - const chipLabel = t('options.chipAriaLabel', { id: formatWorkPackageId(wp.displayId) }); - const hasIndicator = preview.indicatorProps !== undefined; + const showPreview = size === 'xxs' && previewOpen && !options.isOpen; + const menuButton = menuButtonProps(workPackageLabel(t, wp.displayId), options.isOpen); return ( - {size === 'xxs' && } - {size === 'xs' && } - {size === 's' && } + {size === 'xxs' && } + {size === 'xs' && } + {size === 's' && } {showPreview && ( )} - {isSelected && optionsPopover} + {options.isOpen && optionsPopover} ); } // Unavailable: the work package exists but this user cannot see it, or the fetch failed - if (wpid && (unauthorized || error)) { + if (wpid && unavailableKind) { return ( ); } diff --git a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/UnavailableChip.tsx b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/UnavailableChip.tsx index 5d8e98ebdb68..4db1224e57e8 100644 --- a/extensions/blocknote-extensions/lib/components/InlineWorkPackage/UnavailableChip.tsx +++ b/extensions/blocknote-extensions/lib/components/InlineWorkPackage/UnavailableChip.tsx @@ -8,18 +8,20 @@ import { WpPreviewPopover } from '../WorkPackage/PreviewPopover'; import { UnavailableCard } from '../WorkPackage/UnavailableCard'; import { formatWorkPackageId } from '../../utils/id'; import { PreviewIndicator } from './PreviewIndicator'; -import { buttonActivationProps } from '../../utils/a11y'; +import { menuButtonProps } from '../../utils/a11y'; +import { describeUnavailableWorkPackage, workPackageLabel, type UnavailableKind } from '../WorkPackage/description'; import type { TapActivationProps } from '../../utils/tapActivation'; import type { InlineWpSize } from '../WorkPackage/types'; import type { WorkPackagePreview } from '../../hooks/useWorkPackagePreview'; export interface UnavailableChipProps { - kind:'unauthorized' | 'error'; + kind:UnavailableKind; size:InlineWpSize; displayId:string; setRef:(node:HTMLElement | null) => void; anchorEl:HTMLElement | null; selected:boolean; + optionsOpen:boolean; preview:WorkPackagePreview; onActivation:TapActivationProps; optionsPopover:ReactNode; @@ -36,12 +38,13 @@ export const UnavailableChip = ({ setRef, anchorEl, selected, + optionsOpen, preview, onActivation, optionsPopover, }:UnavailableChipProps) => { const { t } = useTranslation(); - const { previewOpen, triggerProps, indicatorProps, cardProps } = preview; + const { previewOpen, triggerProps, cardProps } = preview; const shortLabel = t(`unavailableWorkPackage.${kind}.short_message`); const inlineIcon = kind === 'unauthorized' @@ -57,22 +60,23 @@ export const UnavailableChip = ({ const iconOnly = size === 'xxs'; const Base = iconOnly ? ChipBaseXXS : ChipBase; const showPreview = iconOnly && previewOpen; - const hasIndicator = indicatorProps !== undefined; + // Icon-only, the icon stands in for the identifier as what opens the options. + const menuButton = menuButtonProps( + iconOnly ? describeUnavailableWorkPackage(t, displayId, kind) : workPackageLabel(t, displayId), + optionsOpen, + ); return ( - {hasIndicator ? {inlineIcon} : inlineIcon} - {!iconOnly && {formatWorkPackageId(displayId)}} + {iconOnly ? {inlineIcon} : inlineIcon} + {!iconOnly && {formatWorkPackageId(displayId)}} {!iconOnly && ( {linked diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx index 22f8e464b175..e20393d6bc98 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/OptionsPopover.tsx @@ -1,10 +1,10 @@ -import { useState, useRef } from 'react'; +import { useEffect, useEffectEvent, useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { WorkPackage } from '../../openProjectTypes'; import { linkToWorkPackage } from '../../services/openProjectApi'; import type { InlineWpSize, BlockWpSize } from './types'; import styled from 'styled-components'; -import { defaultWpVariables, menuSurfaceStyles } from './atoms'; +import { defaultWpVariables, menuItemFocusStyles, menuSurfaceStyles } from './atoms'; import { useAnchoredPopover, PopoverPortal } from './anchoredPopover'; import { SizeMenu } from './SizeMenu'; import { FLOATING_Z_INDEX } from '../../utils/zIndex'; @@ -15,6 +15,7 @@ import { } from '@primer/octicons-react'; import {formatWorkPackageId} from '../../utils/id'; import { useTapActivation } from '../../utils/tapActivation'; +import { focusMenuItem, hasFocusWithin, isKeyboardClick, KEYBOARD_FOCUS, navigateMenu } from '../../utils/a11y'; export interface WpOptionsProps { wp?:WorkPackage; @@ -22,7 +23,9 @@ export interface WpOptionsProps { currentSize?:InlineWpSize; currentBlockSize?:BlockWpSize; anchorEl?:HTMLElement | null; + autoFocus?:boolean; onClose:() => void; + onRestoreFocus?:() => void; onResize?:(size:InlineWpSize) => void; onRemove?:() => void; onConvertToBlock?:(size:BlockWpSize) => void; @@ -64,16 +67,18 @@ const PopBtn = styled.button<{ $danger?:boolean }>` align-items: center; gap: var(--spacer-s); line-height: 1; - &:hover { + &:hover, + &:focus-visible { background-color: var( --bn-colors-highlights-gray-background, #f5f5f5 ); } + ${menuItemFocusStyles} svg { flex-shrink: 0; } `; -const Divider = styled.div` +const Divider = styled.div.attrs({ role: 'separator', 'aria-orientation': 'vertical' as const })` width: 1px; height: 18px; background: var(--mantine-color-default-border); @@ -84,6 +89,8 @@ const SizeButtonWrapper = styled.div` position: relative; `; +const CLOSING_KEYS = ['Escape', 'Tab']; + const IcOpen = () => ; const IcDelete = () => ; const IcChevron = () => ; @@ -94,7 +101,9 @@ export const WpOptionsPopover = ({ currentSize, currentBlockSize, anchorEl, + autoFocus = false, onClose, + onRestoreFocus, onResize, onRemove, onConvertToBlock, @@ -103,6 +112,8 @@ export const WpOptionsPopover = ({ }:WpOptionsProps) => { const { t } = useTranslation(); const [showSizes, setShowSizes] = useState(false); + const [sizesTakeFocus, setSizesTakeFocus] = useState(false); + const sizeMenuId = useId(); const tapProps = useTapActivation(); const popoverRef = useRef(null); @@ -118,8 +129,38 @@ export const WpOptionsPopover = ({ const displayedSize = t(`sizes.${displayedSizeKey}.label`); const closeMenu = () => { + const restoreFocus = hasFocusWithin(popoverRef.current); setShowSizes(false); onClose(); + if (restoreFocus) onRestoreFocus?.(); + }; + + const closeFromEditor = useEffectEvent((event:KeyboardEvent) => { + if (!CLOSING_KEYS.includes(event.key) || hasFocusWithin(popoverRef.current)) return; + event.preventDefault(); + event.stopPropagation(); + closeMenu(); + }); + + useEffect(() => { + document.addEventListener('keydown', closeFromEditor, { capture: true }); + return () => document.removeEventListener('keydown', closeFromEditor, { capture: true }); + }, []); + + useEffect(() => { + if (autoFocus) focusMenuItem(popoverRef.current, 'first'); + }, [autoFocus]); + + const closeSizes = () => { + setShowSizes(false); + sizeButtonEl?.focus(KEYBOARD_FOCUS); + }; + + const handleKeyDown = (event:React.KeyboardEvent) => { + if (navigateMenu(event, 'horizontal')) return; + if (!CLOSING_KEYS.includes(event.key)) return; + event.preventDefault(); + closeMenu(); }; const pickInlineSize = (size:InlineWpSize) => { @@ -146,12 +187,18 @@ export const WpOptionsPopover = ({ // every button then needs a priming tap. e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} > {openId && ( <> { @@ -169,10 +216,16 @@ export const WpOptionsPopover = ({ { event?.stopPropagation(); + setSizesTakeFocus(isKeyboardClick(event)); setShowSizes((prev) => !prev); })} > @@ -182,7 +235,10 @@ export const WpOptionsPopover = ({ {showSizes && ( { event?.stopPropagation(); onRemove?.(); - onClose(); + closeMenu(); })} > {t('options.remove')} diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/SizeMenu.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/SizeMenu.tsx index 4b479af74afe..0b77e309ed9f 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/SizeMenu.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/SizeMenu.tsx @@ -1,9 +1,10 @@ -import { Fragment, useRef } from 'react'; +import { Fragment, useEffect, useId, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; -import { menuSurfaceStyles } from './atoms'; +import { menuItemFocusStyles, menuSurfaceStyles } from './atoms'; import { useAnchoredPopover } from './anchoredPopover'; import { useTapActivation } from '../../utils/tapActivation'; +import { focusMenuItem, navigateMenu } from '../../utils/a11y'; import type { BlockWpSize, InlineWpSize, WpSize } from './types'; const MENU_OFFSET = 4; @@ -15,6 +16,7 @@ const BLOCK_SIZE_OPTIONS:BlockWpSize[] = ['m']; const Menu = styled.div.attrs({ className: 'op-bn-size-menu', 'data-testid': 'size-menu', + role: 'menu', })` position: absolute; top: 0; @@ -37,13 +39,13 @@ const MenuLabel = styled.div` letter-spacing: 0.05em; `; -const MenuDivider = styled.div` +const MenuDivider = styled.div.attrs({ role: 'separator' })` height: 1px; background: var(--mantine-color-default-border); margin: var(--spacer-s) 0; `; -const SizeButton = styled.button<{ $active?:boolean }>` +const SizeButton = styled.button.attrs({ role: 'menuitemradio', tabIndex: -1 })<{ $active?:boolean }>` display: flex; align-items: center; gap: var(--spacer-m); @@ -59,7 +61,9 @@ const SizeButton = styled.button<{ $active?:boolean }>` font-size: 0.82em; color: var(--bn-colors-editor-text, #333); text-align: left; - &:hover { background: var(--bn-colors-highlights-gray-background, #f0f0f0); } + &:hover, + &:focus-visible { background: var(--bn-colors-highlights-gray-background, #f0f0f0); } + ${menuItemFocusStyles} `; const SizeButtonLabel = styled.strong` @@ -81,14 +85,21 @@ interface SizeGroup { } export interface SizeMenuProps { + id?:string; anchorEl?:HTMLElement | null; + autoFocus?:boolean; + // Escape closes only this menu and hands the focus back to the item that opened it. + onDismiss:() => void; activeSize:WpSize; onPickInlineSize:(size:InlineWpSize) => void; onPickBlockSize:(size:BlockWpSize) => void; } export const SizeMenu = ({ + id, anchorEl, + autoFocus = false, + onDismiss, activeSize, onPickInlineSize, onPickBlockSize, @@ -96,6 +107,19 @@ export const SizeMenu = ({ const { t } = useTranslation(); const menuRef = useRef(null); const tapProps = useTapActivation(); + const idPrefix = useId(); + + useEffect(() => { + if (autoFocus) focusMenuItem(menuRef.current, 'checked'); + }, [autoFocus]); + + const handleKeyDown = (event:React.KeyboardEvent) => { + if (navigateMenu(event, 'vertical')) return; + if (event.key !== 'Escape' && event.key !== 'ArrowLeft') return; + event.preventDefault(); + event.stopPropagation(); + onDismiss(); + }; useAnchoredPopover({ anchorEl, @@ -123,23 +147,33 @@ export const SizeMenu = ({ ]; return ( - + {groups.map(({ labelKey, options }, groupIndex) => ( {groupIndex > 0 && } - {t(labelKey)} - {options.map(({ size, pick }) => ( - event.preventDefault()} - {...tapProps(pick)} - > - {t(`sizes.${size}.label`)} - {t(`sizes.${size}.desc`)} - - ))} +
+ {t(labelKey)} + {options.map(({ size, pick }) => ( + event.preventDefault()} + // Kept from the chip the menu is rendered in, which would open the options again. + {...tapProps((event) => { + event?.stopPropagation(); + pick(); + })} + > + {t(`sizes.${size}.label`)} + + {t(`sizes.${size}.desc`)} + + + ))} +
))}
diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx index 71720fe08d26..8f96611fcc31 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/UnavailableCard.tsx @@ -9,6 +9,7 @@ import { } from './atoms'; import { CHIP_STYLES } from './tokens'; import { formatWorkPackageId } from '../../utils/id'; +import type { MenuButtonProps } from '../../utils/a11y'; interface UnavailableCardProps { headerKey:string; @@ -16,6 +17,7 @@ interface UnavailableCardProps { icon?:ReactNode; displayId?:string; linkHeader?:boolean; + menuButton?:MenuButtonProps; } const UnavailableWorkPackage = styled.div.attrs({ @@ -42,7 +44,7 @@ const UnavailableMessageHeader = styled.div.attrs({ gap: ${CHIP_STYLES.gap}; `; -export const UnavailableCard = ({ headerKey, messageKey, icon, displayId, linkHeader }:UnavailableCardProps) => { +export const UnavailableCard = ({ headerKey, messageKey, icon, displayId, linkHeader, menuButton }:UnavailableCardProps) => { const { t } = useTranslation(); const header = t(headerKey); @@ -52,7 +54,7 @@ export const UnavailableCard = ({ headerKey, messageKey, icon, displayId, linkHe {icon} - {displayId && {formatWorkPackageId(displayId)}} + {displayId && {formatWorkPackageId(displayId)}} {linkHeader && displayId ? {header} diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx b/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx index bd5b3a00a85e..141c607b3b43 100644 --- a/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/atoms.tsx @@ -40,6 +40,14 @@ export const menuSurfaceStyles = css` color: var(--bn-colors-menu-text, #3f3f3f); `; +// A box-shadow, not an outline: OpenProject removes every focus outline inside +// the editor with `outline: none !important`. +export const menuItemFocusStyles = css` + &:focus-visible { + box-shadow: inset 0 0 0 2px var(--blocknote-focus-color); + } +`; + // The -webkit- prefix is not redundant: Safari (including iOS) implements only the prefixed // property and styled-components v6 no longer auto-prefixes, so unprefixed alone leaves the // chip text selectable there. Belongs on the container only: `user-select` is not inherited, diff --git a/extensions/blocknote-extensions/lib/components/WorkPackage/description.ts b/extensions/blocknote-extensions/lib/components/WorkPackage/description.ts new file mode 100644 index 000000000000..3adf022dff5c --- /dev/null +++ b/extensions/blocknote-extensions/lib/components/WorkPackage/description.ts @@ -0,0 +1,40 @@ +import type { TFunction } from 'i18next'; +import type { WorkPackage } from '../../openProjectTypes'; +import { formatWorkPackageId } from '../../utils/id'; + +export type UnavailableKind = 'unauthorized' | 'error'; + +export function unavailableKindOf( + { unauthorized, error }:{ unauthorized:boolean; error:string | null }, +):UnavailableKind | undefined { + if (unauthorized) return 'unauthorized'; + if (error) return 'error'; + return undefined; +} + +export function workPackageLabel(t:TFunction, displayId:string):string { + return t('options.chipAriaLabel', { id: formatWorkPackageId(displayId) }); +} + +export function describeUnavailableWorkPackage(t:TFunction, displayId:string, kind:UnavailableKind):string { + return `${workPackageLabel(t, displayId)}, ${t(`unavailableWorkPackage.${kind}.short_message`)}`; +} + +// Everything a card of any size shows; nothing while the work package still loads. +export function describeLinkedWorkPackage( + t:TFunction, + displayId:string, + workPackage:WorkPackage | null | undefined, + unavailableKind:UnavailableKind | undefined, +):string | undefined { + if (workPackage) { + return [ + workPackageLabel(t, workPackage.displayId), + workPackage._links?.type?.title, + workPackage._links?.status?.title, + workPackage.subject, + ].filter(Boolean).join(', '); + } + if (unavailableKind) return describeUnavailableWorkPackage(t, displayId, unavailableKind); + return undefined; +} diff --git a/extensions/blocknote-extensions/lib/hooks/useOptionsMenu.ts b/extensions/blocknote-extensions/lib/hooks/useOptionsMenu.ts new file mode 100644 index 000000000000..ffae3ba3d318 --- /dev/null +++ b/extensions/blocknote-extensions/lib/hooks/useOptionsMenu.ts @@ -0,0 +1,27 @@ +import { useCallback, useState } from 'react'; + +interface OptionsMenuState { + isOpen:boolean; + // A menu the keyboard opened takes the focus; one a pointer opened leaves it in the editor. + takesFocus:boolean; +} + +const CLOSED:OptionsMenuState = { isOpen: false, takesFocus: false }; + +export interface OptionsMenu extends OptionsMenuState { + open:(takesFocus:boolean) => void; + toggle:(takesFocus:boolean) => void; + close:() => void; +} + +export function useOptionsMenu():OptionsMenu { + const [state, setState] = useState(CLOSED); + + const open = useCallback((takesFocus:boolean) => setState({ isOpen: true, takesFocus }), []); + const toggle = useCallback((takesFocus:boolean) => { + setState((previous) => (previous.isOpen ? CLOSED : { isOpen: true, takesFocus })); + }, []); + const close = useCallback(() => setState(CLOSED), []); + + return { ...state, open, toggle, close }; +} diff --git a/extensions/blocknote-extensions/lib/hooks/useSelectionAnnouncement.ts b/extensions/blocknote-extensions/lib/hooks/useSelectionAnnouncement.ts new file mode 100644 index 000000000000..ae93e6676a29 --- /dev/null +++ b/extensions/blocknote-extensions/lib/hooks/useSelectionAnnouncement.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; +import type { RefObject } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { AnyEditor } from '../editorTypes'; +import { isWorkPackageNodeSelected } from '../utils/selection'; +import { WorkPackageAccessibilityExtension } from '../plugins/workPackageAccessibilityExtension'; + +// Announces the work package rendered into `elementRef` each time it becomes +// the node selection, and takes the announcement back once it no longer is. +export function useSelectionAnnouncement( + elementRef:RefObject, + editor:AnyEditor | undefined, + description:string | undefined, +):void { + const { t } = useTranslation(); + const message = description && t('options.selectedAnnouncement', { description }); + + useEffect(() => { + if (!editor || !message) return; + + const announcer = editor.getExtension(WorkPackageAccessibilityExtension); + if (!announcer) return; + + let wasSelected = false; + const followSelection = () => { + const selected = isWorkPackageNodeSelected(editor, elementRef.current); + if (selected && !wasSelected) announcer.announce(message); + if (!selected && wasSelected) announcer.clearAnnouncement(); + wasSelected = selected; + }; + + const unsubscribe = editor.onSelectionChange(followSelection); + // The work package may have been selected before there was anything to announce. + followSelection(); + return unsubscribe; + }, [editor, elementRef, message]); +} diff --git a/extensions/blocknote-extensions/lib/locales/en.ts b/extensions/blocknote-extensions/lib/locales/en.ts index 774c820ef663..0f8b708435ce 100644 --- a/extensions/blocknote-extensions/lib/locales/en.ts +++ b/extensions/blocknote-extensions/lib/locales/en.ts @@ -77,6 +77,9 @@ export const en = { 'openInNewTab': 'Open in new tab', 'open': 'Open', 'changeSize': 'Change size', + 'changeSizeAriaLabel': 'Change size: {{size}}', + 'menuAriaLabel': 'Options for work package {{id}}', + 'selectedAnnouncement': '{{description}}. Press Enter to open its options.', 'remove': 'Remove', 'removeAriaLabel': 'Remove work package', 'openAriaLabel': 'Open work package {{id}} in new tab', diff --git a/extensions/blocknote-extensions/lib/plugins/workPackageAccessibilityExtension.ts b/extensions/blocknote-extensions/lib/plugins/workPackageAccessibilityExtension.ts new file mode 100644 index 000000000000..f6271c2f17b8 --- /dev/null +++ b/extensions/blocknote-extensions/lib/plugins/workPackageAccessibilityExtension.ts @@ -0,0 +1,107 @@ +import { createExtension } from '@blocknote/core'; +import { TextSelection } from 'prosemirror-state'; +import type { AnyEditor } from '../editorTypes'; +import { selectedWorkPackageDom } from '../utils/selection'; +import { openOptionsWithin } from '../utils/optionsHost'; +import { selectInlineChipAt } from '../utils/inlineChipActions'; +import { INLINE_WP_TYPE } from '../utils/nodeTypes'; + +// Kept out of the tab order, a work package is operated through the editor +// itself: the arrow keys select it and Enter or Space opens its options. A +// NodeSelection tells a screen reader nothing, so it is announced instead. + +function createLiveRegion(ownerDocument:Document):HTMLElement { + const region = ownerDocument.createElement('div'); + region.setAttribute('role', 'status'); + region.className = 'op-bn-live-region'; + Object.assign(region.style, { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + }); + return region; +} + +// BlockNote makes inline content without content unselectable, so the caret +// would otherwise step over a chip without ever selecting it. +function selectChipBeside(editor:AnyEditor, side:'before' | 'after'):boolean { + const { selection } = editor.prosemirrorState; + if (!(selection instanceof TextSelection) || !selection.$cursor) return false; + + const { $cursor } = selection; + const chip = side === 'before' ? $cursor.nodeBefore : $cursor.nodeAfter; + if (chip?.type.name !== INLINE_WP_TYPE) return false; + + selectInlineChipAt(editor, side === 'before' ? $cursor.pos - chip.nodeSize : $cursor.pos); + return true; +} + +// Swallowed whenever a work package is selected: let through, the key would +// replace it with a line break or a space. +function openSelectedOptions(editor:AnyEditor):boolean { + const dom = selectedWorkPackageDom(editor); + if (!dom) return false; + + openOptionsWithin(dom); + return true; +} + +// BlockNote shows its formatting toolbar for any selection that is not empty, +// though there is nothing to format on a work package. +function keepFormattingToolbarClosed(editor:AnyEditor):(() => void) | undefined { + const store = editor.getExtension('formattingToolbar')?.store; + + return store?.subscribe(() => { + if (store.state && selectedWorkPackageDom(editor)) store.setState(false); + }); +} + +export const WorkPackageAccessibilityExtension = createExtension(({ editor }:{ editor:AnyEditor }) => { + let liveRegion:HTMLElement | null = null; + let pendingAnnouncement = 0; + + const clearAnnouncement = () => { + cancelAnimationFrame(pendingAnnouncement); + if (liveRegion) liveRegion.textContent = ''; + }; + + // Emptied first, so that selecting the same work package again is announced again. + const announce = (message:string) => { + clearAnnouncement(); + pendingAnnouncement = requestAnimationFrame(() => { + if (liveRegion) liveRegion.textContent = message; + }); + }; + + return { + key: 'workPackageAccessibility', + + mount: ({ dom, signal }) => { + const region = createLiveRegion(dom.ownerDocument); + dom.after(region); + liveRegion = region; + + const releaseFormattingToolbar = keepFormattingToolbarClosed(editor); + + signal.addEventListener('abort', () => { + releaseFormattingToolbar?.(); + clearAnnouncement(); + region.remove(); + liveRegion = null; + }); + }, + + keyboardShortcuts: { + ArrowLeft: () => selectChipBeside(editor, 'before'), + ArrowRight: () => selectChipBeside(editor, 'after'), + Enter: () => openSelectedOptions(editor), + Space: () => openSelectedOptions(editor), + }, + + announce, + clearAnnouncement, + }; +}); diff --git a/extensions/blocknote-extensions/lib/utils/a11y.ts b/extensions/blocknote-extensions/lib/utils/a11y.ts index 35b1ca885d9e..2a4d80fe86df 100644 --- a/extensions/blocknote-extensions/lib/utils/a11y.ts +++ b/extensions/blocknote-extensions/lib/utils/a11y.ts @@ -1,14 +1,80 @@ -import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; +import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'; -export function buttonActivationProps(label:string) { +export function menuButtonProps(label:string, expanded:boolean) { return { role: 'button' as const, - tabIndex: 0, 'aria-label': label, - onKeyDown: (event:ReactKeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - event.currentTarget.click(); - }, + 'aria-haspopup': 'menu' as const, + 'aria-expanded': expanded, }; } + +export type MenuButtonProps = ReturnType; + +// A click the keyboard produced (Enter or Space on a button) carries no pointer detail. +export function isKeyboardClick(event?:ReactMouseEvent):boolean { + return event?.detail === 0; +} + +function getActiveElement(node:Node):Element | null { + const root = node.getRootNode() as Document | ShadowRoot; + return root.activeElement; +} + +export function hasFocusWithin(element:Element | null):boolean { + if (!element) return false; + const active = getActiveElement(element); + return active !== null && element.contains(active); +} + +const MENU_ITEM_SELECTOR = '[role="menuitem"], [role="menuitemradio"]'; + +type MenuItemTarget = 'first' | 'last' | 'next' | 'previous' | 'checked'; + +// Items of nested menus belong to those menus, not to this one. +function itemsOf(menu:Element):HTMLElement[] { + return Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR)) + .filter((item) => item.closest('[role="menu"]') === menu); +} + +// Without focusVisible, Firefox hides the ring once the editor was clicked. +export const KEYBOARD_FOCUS:FocusOptions = { preventScroll: true, focusVisible: true }; + +export function focusMenuItem(menu:Element | null, target:MenuItemTarget):void { + if (!menu) return; + const items = itemsOf(menu); + if (items.length === 0) return; + + const current = items.findIndex((item) => item === getActiveElement(menu)); + const checked = items.find((item) => item.getAttribute('aria-checked') === 'true'); + + const next = { + first: items[0], + last: items[items.length - 1], + next: items[(current + 1) % items.length], + previous: items[(current - 1 + items.length) % items.length], + checked: checked ?? items[0], + }[target]; + + next.focus(KEYBOARD_FOCUS); +} + +const NAVIGATION_KEYS = { + horizontal: { ArrowRight: 'next', ArrowLeft: 'previous', Home: 'first', End: 'last' }, + vertical: { ArrowDown: 'next', ArrowUp: 'previous', Home: 'first', End: 'last' }, +} as const satisfies Record>; + +// Returns whether the key was one of the menu's navigation keys. +export function navigateMenu( + event:ReactKeyboardEvent, + orientation:keyof typeof NAVIGATION_KEYS, +):boolean { + const keys:Partial> = NAVIGATION_KEYS[orientation]; + const target = keys[event.key]; + if (!target) return false; + + event.preventDefault(); + event.stopPropagation(); + focusMenuItem(event.currentTarget, target); + return true; +} diff --git a/extensions/blocknote-extensions/lib/utils/optionsHost.ts b/extensions/blocknote-extensions/lib/utils/optionsHost.ts new file mode 100644 index 000000000000..be6cce0c4756 --- /dev/null +++ b/extensions/blocknote-extensions/lib/utils/optionsHost.ts @@ -0,0 +1,41 @@ +import { useCallback, useLayoutEffect, useRef } from 'react'; + +// Connects a keyboard shortcut, which only knows the selected ProseMirror node, +// to the React component rendered inside that node that owns its options menu. + +const HOST_ATTRIBUTE = 'data-op-bn-options-host'; + +const openers = new WeakMap void>(); + +export function openOptionsWithin(nodeDom:Node):void { + if (!(nodeDom instanceof Element)) return; + + const host = nodeDom.matches(`[${HOST_ATTRIBUTE}]`) + ? nodeDom + : nodeDom.querySelector(`[${HOST_ATTRIBUTE}]`); + if (host) openers.get(host)?.(); +} + +export function useOptionsHost(open:() => void):(element:Element | null) => void { + const openRef = useRef(open); + useLayoutEffect(() => { + openRef.current = open; + }); + + const hostRef = useRef(null); + + return useCallback((element:Element | null) => { + if (hostRef.current === element) return; + + if (hostRef.current) { + openers.delete(hostRef.current); + hostRef.current.removeAttribute(HOST_ATTRIBUTE); + } + + hostRef.current = element; + if (!element) return; + + openers.set(element, () => openRef.current()); + element.setAttribute(HOST_ATTRIBUTE, ''); + }, []); +} diff --git a/extensions/blocknote-extensions/lib/utils/selection.ts b/extensions/blocknote-extensions/lib/utils/selection.ts index 72f17bec982b..e6d2e245ec5f 100644 --- a/extensions/blocknote-extensions/lib/utils/selection.ts +++ b/extensions/blocknote-extensions/lib/utils/selection.ts @@ -84,3 +84,16 @@ export function selectBlockNode(editor:AnyEditor, blockId:string):void { }); hideSafariPhantomSelection(editor); } + +/** The DOM of the work package the editor has node-selected, if it has one selected. */ +export function selectedWorkPackageDom(editor:AnyEditor):Node | null { + const { selection } = editor.prosemirrorState; + if (!(selection instanceof NodeSelection)) return null; + if (!WORK_PACKAGE_NODE_TYPES.includes(selection.node.type.name)) return null; + + return editor.prosemirrorView?.nodeDOM(selection.from) ?? null; +} + +export function isWorkPackageNodeSelected(editor:AnyEditor, element:Element | null):boolean { + return Boolean(element && selectedWorkPackageDom(editor)?.contains(element)); +} diff --git a/extensions/blocknote-extensions/package-lock.json b/extensions/blocknote-extensions/package-lock.json index ff2e64830642..b4a304076e4d 100644 --- a/extensions/blocknote-extensions/package-lock.json +++ b/extensions/blocknote-extensions/package-lock.json @@ -56,7 +56,8 @@ "peerDependencies": { "@blocknote/core": ">=0.54.2", "@blocknote/mantine": ">=0.54.2", - "@blocknote/react": ">=0.54.2" + "@blocknote/react": ">=0.54.2", + "prosemirror-state": "^1.4.4" }, "peerDependenciesMeta": { "@blocknote/mantine": { @@ -4399,7 +4400,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", - "dev": true, "license": "MIT" }, "node_modules/outvariant": { @@ -4778,7 +4778,6 @@ "version": "1.25.12", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", - "dev": true, "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" @@ -4800,7 +4799,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", - "dev": true, "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", @@ -4826,7 +4824,6 @@ "version": "1.12.2", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz", "integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==", - "dev": true, "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" @@ -4836,7 +4833,6 @@ "version": "1.42.6", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz", "integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==", - "dev": true, "license": "MIT", "dependencies": { "prosemirror-model": "^1.25.12", diff --git a/extensions/blocknote-extensions/package.json b/extensions/blocknote-extensions/package.json index bc34c1807ab8..017c7e5db375 100644 --- a/extensions/blocknote-extensions/package.json +++ b/extensions/blocknote-extensions/package.json @@ -41,7 +41,9 @@ "@blocknote/react", "@blocknote/mantine", "yjs", - "use-sync-external-store" + "use-sync-external-store", + "prosemirror-model", + "prosemirror-state" ], "scripts": { "dev": "vite", @@ -91,7 +93,8 @@ "peerDependencies": { "@blocknote/core": ">=0.54.2", "@blocknote/mantine": ">=0.54.2", - "@blocknote/react": ">=0.54.2" + "@blocknote/react": ">=0.54.2", + "prosemirror-state": "^1.4.4" }, "peerDependenciesMeta": { "@blocknote/mantine": { "optional": true }, diff --git a/extensions/blocknote-extensions/test/helpers/editorHelpers.ts b/extensions/blocknote-extensions/test/helpers/editorHelpers.ts index 06a65ef45239..5b51687b18dc 100644 --- a/extensions/blocknote-extensions/test/helpers/editorHelpers.ts +++ b/extensions/blocknote-extensions/test/helpers/editorHelpers.ts @@ -3,6 +3,14 @@ import { page, userEvent } from 'vitest/browser'; export const SEARCH_PLACEHOLDER = 'Search by work package ID or subject'; +// BlockNote leaves an empty display:none toolbar container in the DOM when +// closed, so presence alone is not enough - only a rendered one counts. +export const formattingToolbarVisible = () => + Array.from(document.querySelectorAll('[class*="formatting-toolbar"]')).some((el) => { + const he = el as HTMLElement; + return getComputedStyle(he).display !== 'none' && he.childElementCount > 0; + }); + function touchAt(element:Element) { const rect = element.getBoundingClientRect(); return new Touch({ @@ -139,7 +147,7 @@ export async function openBlockCardSizeMenu() { export async function convertToCompactCard(displayId = '#123') { await openInlineWorkPackageSizeMenu(displayId); - await userEvent.click(page.getByRole('button', { name: 'Compact card', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact card', exact: true })); await expect.element(page.getByTestId('block-card')).toBeVisible(); } diff --git a/extensions/blocknote-extensions/test/lib/components/integration/blockWorkPackageCopyPaste.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/blockWorkPackageCopyPaste.browser.test.tsx index c03d87c443fc..ffc2a854e2f5 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/blockWorkPackageCopyPaste.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/blockWorkPackageCopyPaste.browser.test.tsx @@ -126,7 +126,7 @@ describe('Block card - copy/paste independence', () => { await expect.element(page.getByTestId('popover-content')).toBeVisible(); await userEvent.click(page.getByTitle('Change size')); await expect.element(page.getByTestId('size-menu')).toBeVisible(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); // The pasted copy still shows the status await expect.element(page.getByText('In Progress').nth(0)).toBeVisible(); @@ -147,7 +147,7 @@ describe('Block card - copy/paste independence', () => { await expect.element(page.getByTestId('popover-content')).toBeVisible(); await userEvent.click(page.getByTitle('Change size')); await expect.element(page.getByTestId('size-menu')).toBeVisible(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); // The original still shows the status await expect.element(page.getByText('In Progress').nth(0)).toBeVisible(); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/editor.blockWorkPackage.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/editor.blockWorkPackage.browser.test.tsx index c895b305c217..3e276d57a62f 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/editor.blockWorkPackage.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/editor.blockWorkPackage.browser.test.tsx @@ -15,7 +15,7 @@ describe('Block card - resize', () => { // await convertToCompactCard(); // await openBlockCardSizeMenu(); - // await userEvent.click(page.getByRole('button', { name: 'Regular card', exact: true })); + // await userEvent.click(page.getByRole('menuitemradio', { name: 'Regular card', exact: true })); // await expect.element(page.getByTestId('block-card')).toBeVisible(); // await expect.element(page.getByText('Fix login bug')).toBeVisible(); @@ -27,11 +27,11 @@ describe('Block card - resize', () => { // await insertInlineWorkPackageViaSlashMenu(); // await openBlockCardSizeMenu(); - // await userEvent.click(page.getByRole('button', { name: 'Regular card', exact: true })); + // await userEvent.click(page.getByRole('menuitemradio', { name: 'Regular card', exact: true })); // await expect.element(page.getByTestId('block-card')).toBeVisible(); // await openBlockCardSizeMenu(); - // await userEvent.click(page.getByRole('button', { name: 'Full card', exact: true })); + // await userEvent.click(page.getByRole('menuitemradio', { name: 'Full card', exact: true })); // await expect.element(page.getByTestId('block-card')).toBeVisible(); // await expect.element(page.getByText('Fix login bug')).toBeVisible(); @@ -60,7 +60,7 @@ describe('Block card - resize', () => { 'Regular', 'Compact card', ]) { - await expect.element(page.getByRole('button', { name: label, exact: true })).toBeVisible(); + await expect.element(page.getByRole('menuitemradio', { name: label, exact: true })).toBeVisible(); } }); @@ -70,7 +70,7 @@ describe('Block card - resize', () => { await convertToCompactCard(); await openBlockCardSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Compact card', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact card', exact: true })); await expect.element(page.getByTestId('size-menu')).not.toBeInTheDocument(); }); @@ -136,14 +136,4 @@ describe('Block card - selection', () => { // user-select: all made a single click highlight the whole card's text. expect(getComputedStyle(wrapper).userSelect).toBe('none'); }); - - // role=button is what makes iOS fire the click on the first tap (a plain div - // in the contenteditable needs two); see BlockCards. - it('the clickable card exposes role=button', async () => { - renderEditor(); - await insertInlineWorkPackageViaSlashMenu(); - await convertToCompactCard(); - - expect(page.getByTestId('block-card').element().getAttribute('role')).toBe('button'); - }); }); \ No newline at end of file diff --git a/extensions/blocknote-extensions/test/lib/components/integration/editor.conversion.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/editor.conversion.browser.test.tsx index c6e7ddd46dc7..18b3d05d4b85 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/editor.conversion.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/editor.conversion.browser.test.tsx @@ -28,7 +28,7 @@ describe('Inline chip - convert to block card', () => { // await insertInlineWorkPackageViaSlashMenu(); // await openInlineWorkPackageSizeMenu(); - // await userEvent.click(page.getByRole('button', { name: 'Regular card', exact: true })); + // await userEvent.click(page.getByRole('menuitemradio', { name: 'Regular card', exact: true })); // await expect.element(page.getByTestId('block-card')).toBeVisible(); // await expect.element(page.getByText('Fix login bug')).toBeVisible(); @@ -43,7 +43,7 @@ describe('Block card - convert to inline chip', () => { await convertToCompactCard(); await openBlockCardSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); await expect.element(page.getByTestId('block-card')).not.toBeInTheDocument(); await expect.element(page.getByText('#123')).toBeVisible(); @@ -57,7 +57,7 @@ describe('Block card - convert to inline chip', () => { await convertToCompactCard(); await openBlockCardSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Compact', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact', exact: true })); await expect.element(page.getByTestId('block-card')).not.toBeInTheDocument(); await expect.element(page.getByText('#123')).toBeVisible(); @@ -72,7 +72,7 @@ describe('Block card - convert to inline chip', () => { await convertToCompactCard(); await openBlockCardSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Regular', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Regular', exact: true })); await expect.element(page.getByTestId('block-card')).not.toBeInTheDocument(); await expect.element(page.getByText('#123')).toBeVisible(); @@ -87,7 +87,7 @@ describe('Inline chip -> block: surrounding text is split at chip position', () await userEvent.keyboard(' World'); await openInlineWorkPackageSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Compact card', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact card', exact: true })); await expect.element(page.getByTestId('block-card')).toBeVisible(); await expect.element(page.getByText('Fix login bug')).toBeVisible(); @@ -106,7 +106,7 @@ describe('Inline chip -> block: surrounding text is split at chip position', () await userEvent.keyboard(' World'); await openInlineWorkPackageSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Compact card', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact card', exact: true })); await expect.element(page.getByTestId('block-card')).toBeVisible(); await expect.element(page.getByText('Fix login bug')).toBeVisible(); @@ -129,7 +129,7 @@ describe('Inline chip -> block: surrounding text is split at chip position', () await insertInlineWorkPackageViaHashWithTextBefore('Hello '); await openInlineWorkPackageSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Compact card', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact card', exact: true })); await expect.element(page.getByTestId('block-card')).toBeVisible(); await expect.element(page.getByText('Fix login bug')).toBeVisible(); @@ -165,7 +165,7 @@ describe('Inline chip -> block: surrounding text is split at chip position', () // Convert middle chip (#456) to block card await openInlineWorkPackageSizeMenu('#456'); - await userEvent.click(page.getByRole('button', { name: 'Compact card', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact card', exact: true })); await expect.element(page.getByTestId('block-card')).toBeVisible(); await expect.element(page.getByText('Add dark mode')).toBeVisible(); @@ -202,7 +202,7 @@ describe('Round-trip conversion', () => { // back to inline S await openBlockCardSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Regular', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Regular', exact: true })); await expect.element(page.getByTestId('block-card')).not.toBeInTheDocument(); await expect.element(page.getByText('#123')).toBeVisible(); @@ -216,12 +216,12 @@ describe('Round-trip conversion', () => { // block await openInlineWorkPackageSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Compact card', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact card', exact: true })); await expect.element(page.getByTestId('block-card')).toBeVisible(); // back to XXS inline await openBlockCardSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); await expect.element(page.getByTestId('block-card')).not.toBeInTheDocument(); await expect.element(page.getByText('#123')).toBeVisible(); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/editor.inlineWorkPackage.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/editor.inlineWorkPackage.browser.test.tsx index 9108beb12dbc..f6d3d94714a0 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/editor.inlineWorkPackage.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/editor.inlineWorkPackage.browser.test.tsx @@ -53,7 +53,7 @@ describe('Inline chip - resize', () => { await insertInlineWorkPackageViaSlashMenu(); await openInlineWorkPackageSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); await expect.element(page.getByText('#123')).toBeVisible(); await expect.element(page.getByTestId('op-bn-work-package--type')).not.toBeInTheDocument(); @@ -66,7 +66,7 @@ describe('Inline chip - resize', () => { await insertInlineWorkPackageViaSlashMenu(); await openInlineWorkPackageSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Compact', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact', exact: true })); await expect.element(page.getByText('#123')).toBeVisible(); await expect.element(page.getByTestId('op-bn-work-package--type')).toBeVisible(); @@ -79,13 +79,24 @@ describe('Inline chip - resize', () => { await insertInlineWorkPackageViaHash('##'); await openInlineWorkPackageSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); await expect.element(page.getByText('#123')).toBeVisible(); await expect.element(page.getByTestId('op-bn-work-package--type')).not.toBeInTheDocument(); await expect.element(page.getByText('In Progress')).not.toBeInTheDocument(); await expect.element(page.getByText('Fix login bug')).not.toBeInTheDocument(); }); + + it('closes the options once a size is picked', async () => { + renderEditor(); + await insertInlineWorkPackageViaSlashMenu(); + + await openInlineWorkPackageSizeMenu(); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact', exact: true })); + + await expect.element(page.getByText('In Progress')).not.toBeInTheDocument(); + await expect.element(page.getByTestId('popover-content')).not.toBeInTheDocument(); + }); }); describe('Inline chip - popover UX', () => { diff --git a/extensions/blocknote-extensions/test/lib/components/integration/editor.undo.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/editor.undo.browser.test.tsx index 655678254351..3a5eeaccca13 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/editor.undo.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/editor.undo.browser.test.tsx @@ -97,13 +97,13 @@ describe('Undo (Ctrl+Z)', () => { await expect.element(page.getByTestId('block-card')).toBeVisible(); await openBlockCardSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Regular', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Regular', exact: true })); await expect.element(page.getByTestId('block-card')).not.toBeInTheDocument(); await ctrl_z(); await expect.element(page.getByTestId('block-card')).toBeVisible(); - await expect.element(page.getByText('Fix login bug')).toBeVisible(); + await expect.element(page.getByText('Fix login bug', { exact: true })).toBeVisible(); }); it('undoes inline size change (S -> XS) — status is visible again', async () => { @@ -112,7 +112,7 @@ describe('Undo (Ctrl+Z)', () => { await expect.element(page.getByText('In Progress')).toBeVisible(); await new Promise(r => setTimeout(r, 600)); await openInlineWorkPackageSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Compact', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact', exact: true })); await expect.element(page.getByText('In Progress')).not.toBeInTheDocument(); await ctrl_z(); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackageCopyPaste.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackageCopyPaste.browser.test.tsx index 4181151c9ebf..50dfc0e900af 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackageCopyPaste.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackageCopyPaste.browser.test.tsx @@ -39,7 +39,7 @@ describe('Inline chip - copy/paste independence', () => { await expect.element(page.getByTestId('popover-content')).toBeVisible(); await userEvent.click(page.getByTitle('Change size')); await expect.element(page.getByTestId('size-menu')).toBeVisible(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); const statusBadges = page.getByText('In Progress'); expect((await statusBadges.all()).length).toBe(1); @@ -60,7 +60,7 @@ describe('Inline chip - copy/paste independence', () => { await expect.element(page.getByTestId('popover-content')).toBeVisible(); await userEvent.click(page.getByTitle('Change size')); await expect.element(page.getByTestId('size-menu')).toBeVisible(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); const statusBadges = page.getByText('In Progress'); expect((await statusBadges.all()).length).toBe(1); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackagePreview.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackagePreview.browser.test.tsx index 74bc625d2770..06d8d4dd0ddf 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackagePreview.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackagePreview.browser.test.tsx @@ -253,11 +253,20 @@ describe('Inline chip - XXS preview indicator (touch)', () => { expect(id.getAttribute('aria-label')).toBe('Work package #123'); }); - it('opens the options popover from the id with the keyboard', async () => { - await renderChip(); + it('opens the options popover from the keyboard once the chip is selected', async () => { + let editor:any; + renderEditor({ + onEditor: (created) => { editor = created; }, + initialContent: [{ + type: 'paragraph', + content: [{ type: 'openProjectWorkPackageInline', props: { wpid: '123', size: 'xxs', displayId: '123' } }], + }], + }); + await expect.element(indicator()).toBeVisible(); - page.getByText('#123').first().element() - .dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + editor.focus(); + editor.setTextCursorPosition(editor.document[0], 'end'); + await userEvent.keyboard('{ArrowLeft}{Enter}'); await expect.element(page.getByTestId('popover-content')).toBeVisible(); }); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackageSameParagraph.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackageSameParagraph.browser.test.tsx index 6a76f465f3d1..16eb5f5c40c5 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackageSameParagraph.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/inlineWorkPackageSameParagraph.browser.test.tsx @@ -36,7 +36,7 @@ describe('Inline chip - identical chips in the same paragraph', () => { await expect.element(page.getByTestId('popover-content')).toBeVisible(); await userEvent.click(page.getByTitle('Change size')); await expect.element(page.getByTestId('size-menu')).toBeVisible(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); // Only the second chip shrank to XXS (no status badge); the first chip // stays at S and keeps its status badge. @@ -52,7 +52,7 @@ describe('Inline chip - identical chips in the same paragraph', () => { await expect.element(page.getByTestId('popover-content')).toBeVisible(); await userEvent.click(page.getByTitle('Change size')); await expect.element(page.getByTestId('size-menu')).toBeVisible(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); const statusBadges = page.getByText('In Progress'); expect((await statusBadges.all()).length).toBe(1); @@ -88,7 +88,7 @@ describe('Inline chip - identical chips in the same paragraph', () => { await expect.element(page.getByTestId('popover-content')).toBeVisible(); await userEvent.click(page.getByTitle('Change size')); await expect.element(page.getByTestId('size-menu')).toBeVisible(); - await userEvent.click(page.getByRole('button', { name: 'Compact card', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact card', exact: true })); await expect.element(page.getByTestId('block-card')).toBeVisible(); // The first chip remains an inline chip; only the second became a block card. diff --git a/extensions/blocknote-extensions/test/lib/components/integration/pasteWorkPackageUrl.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/pasteWorkPackageUrl.browser.test.tsx index bcaf5aafe578..329f6596a819 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/pasteWorkPackageUrl.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/pasteWorkPackageUrl.browser.test.tsx @@ -159,7 +159,7 @@ describe('Paste work package URL', () => { { type: 'paragraph', content: [] }, { type: 'openProjectWorkPackageBlock', props: { wpid: 456, displayId: '456', size: 'm' } }, ]); - await expect.element(page.getByText('Add dark mode')).toBeVisible(); + await expect.element(page.getByText('Add dark mode', { exact: true })).toBeVisible(); editor.setTextCursorPosition(editor.document[0].id, 'start'); pastePlainText('http://localhost:3000/wp/123'); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/singleTapActivation.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/singleTapActivation.browser.test.tsx index 8e0f85400f76..0b466d5a6204 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/singleTapActivation.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/singleTapActivation.browser.test.tsx @@ -13,7 +13,7 @@ async function settled(selector:string):Promise { return document.querySelector(selector)!; } -const chip = () => settled('.op-bn-inline-wp[role="button"]'); +const chip = () => settled('.op-bn-inline-wp:has([aria-haspopup="menu"])'); const blockCard = () => settled('[data-testid="block-card"]'); describe('Single tap activation', () => { diff --git a/extensions/blocknote-extensions/test/lib/components/integration/workPackageBatchLoading.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/workPackageBatchLoading.browser.test.tsx index ff904e254399..1f828c0f8ec3 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/workPackageBatchLoading.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/workPackageBatchLoading.browser.test.tsx @@ -29,11 +29,11 @@ describe('Loading the work packages of a document', () => { const requests = await requestsDuring('/api/v3/work_packages', async () => { renderEditor({ initialContent: documentWithManyWorkPackages }); - await expect.element(page.getByText('#123')).toBeVisible(); + await expect.element(page.getByText('#123', { exact: true })).toBeVisible(); await expect.element(page.getByText('Add dark mode').first()).toBeVisible(); await expect.element(page.getByText('Semantic ID work package')).toBeVisible(); await expect.element(page.getByText('Freshly created work package')).toBeVisible(); - await expect.element(page.getByText('#321')).toBeVisible(); + await expect.element(page.getByText('#321', { exact: true })).toBeVisible(); }); expect(requests).toHaveLength(1); @@ -46,7 +46,7 @@ describe('Loading the work packages of a document', () => { const requests = await requestsDuring('/api/v3/work_packages', async () => { renderEditor({ initialContent: documentWithManyWorkPackages }); - await expect.element(page.getByText('#123')).toBeVisible(); + await expect.element(page.getByText('#123', { exact: true })).toBeVisible(); await expect.element(page.getByText('Linked work package unavailable')).toBeVisible(); await expect.element(page.getByText('Work package unavailable: no permission')).toBeVisible(); await expect.element(page.getByText('Semantic ID work package')).toBeVisible(); @@ -69,9 +69,9 @@ describe('Loading the work packages of a document', () => { await expect.element(page.getByText('Could not load work package')).toBeVisible(); await expect.element(page.getByText('Unavailable: error')).toBeVisible(); - await expect.element(page.getByText('#123')).toBeVisible(); + await expect.element(page.getByText('#123', { exact: true })).toBeVisible(); await expect.element(page.getByText('Semantic ID work package')).toBeVisible(); await expect.element(page.getByText('Freshly created work package')).toBeVisible(); - await expect.element(page.getByText('#321')).toBeVisible(); + await expect.element(page.getByText('#321', { exact: true })).toBeVisible(); }); }); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/workPackageKeyboardAccess.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/workPackageKeyboardAccess.browser.test.tsx new file mode 100644 index 000000000000..f71d8f131713 --- /dev/null +++ b/extensions/blocknote-extensions/test/lib/components/integration/workPackageKeyboardAccess.browser.test.tsx @@ -0,0 +1,338 @@ +import { describe, it, expect, onTestFinished } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { http, HttpResponse } from 'msw'; +import { worker } from '../../../mocks/browser'; +import { mockWorkPackage } from '../../../mocks/handlers'; +import { renderEditor, renderEditorInShadowDom } from '../../../helpers/renderEditor'; +import { + formattingToolbarVisible, + openBlockCardPopover, + openInlineWorkPackagePopover, +} from '../../../helpers/editorHelpers'; + +const NEXT_LINE = 'next line'; + +const chipDocument = [ + { + type: 'paragraph', + content: [ + 'before ', + { type: 'openProjectWorkPackageInline', props: { wpid: '123', size: 's', displayId: '123' } }, + ], + }, + { type: 'paragraph', content: NEXT_LINE }, +]; + +const blockDocument = [ + { type: 'paragraph', content: 'above' }, + { type: 'openProjectWorkPackageBlock', props: { wpid: 123, size: 'm', displayId: '123' } }, +]; + +// Placed through the editor: a caret the browser moves natively reaches +// ProseMirror only after a key pressed straight after it has been handled. +function putCaretBehindChip(editor:any) { + editor.focus(); + editor.setTextCursorPosition(editor.document[0], 'end'); +} + +async function renderChipWithCaretBehindIt() { + let editor:any; + renderEditor({ initialContent: chipDocument, onEditor: (created) => { editor = created; } }); + await expect.element(page.getByText('#123', { exact: true })).toBeVisible(); + + putCaretBehindChip(editor); +} + +async function selectChipWithKeyboard() { + await renderChipWithCaretBehindIt(); + await userEvent.keyboard('{ArrowLeft}'); +} + +const optionsMenu = () => page.getByRole('menu', { name: 'Options for work package #123' }); +const openItem = () => page.getByRole('menuitem', { name: 'Open work package #123 in new tab' }); +const sizeItem = () => page.getByRole('menuitem', { name: /^Change size/ }); +const removeItem = () => page.getByRole('menuitem', { name: 'Remove work package' }); +const liveRegion = () => document.querySelector('.op-bn-live-region'); +const firstParagraphText = () => document.querySelector('.bn-inline-content')?.textContent; + +describe('Work package keyboard access - selecting an inline chip', () => { + it('selects the chip with the arrow key before moving past it', async () => { + await selectChipWithKeyboard(); + + await expect.poll(() => liveRegion()?.textContent) + .toBe('Work package #123, Bug, In Progress, Fix login bug. Press Enter to open its options.'); + + await userEvent.keyboard('{ArrowLeft}x'); + expect(firstParagraphText()).toMatch(/^before x#123/); + }); + + it('announces a chip selected while it was still loading once it has loaded', async () => { + let respond = () => {}; + const responded = new Promise((resolve) => { respond = resolve; }); + worker.use(http.get('http://localhost:3000/api/v3/work_packages/123', async () => { + await responded; + return HttpResponse.json(mockWorkPackage); + })); + onTestFinished(() => worker.resetHandlers()); + + let editor:any; + renderEditor({ initialContent: chipDocument, onEditor: (created) => { editor = created; } }); + await expect.element(page.getByText('#123…')).toBeVisible(); + + putCaretBehindChip(editor); + await userEvent.keyboard('{ArrowLeft}'); + respond(); + + await expect.poll(() => liveRegion()?.textContent) + .toBe('Work package #123, Bug, In Progress, Fix login bug. Press Enter to open its options.'); + }); + + it('keeps the formatting toolbar closed while the chip is selected', async () => { + await selectChipWithKeyboard(); + await expect.poll(() => liveRegion()?.textContent).not.toBe(''); + + expect(formattingToolbarVisible()).toBe(false); + }); + + it('selects the chip coming from the other side as well', async () => { + await renderChipWithCaretBehindIt(); + await userEvent.keyboard('{ArrowLeft}{ArrowLeft}{ArrowRight}'); + + await userEvent.keyboard('{Enter}'); + + await expect.element(optionsMenu()).toBeVisible(); + }); +}); + +describe('Work package keyboard access - the options menu of an inline chip', () => { + it('opens on Enter and takes the focus onto its first item', async () => { + await selectChipWithKeyboard(); + + await userEvent.keyboard('{Enter}'); + + await expect.element(optionsMenu()).toBeVisible(); + await expect.element(openItem()).toHaveFocus(); + expect(firstParagraphText()).toMatch(/^before #123/); + await expect.element(page.getByText(NEXT_LINE)).toBeVisible(); + }); + + it('opens on Space without replacing the chip', async () => { + await selectChipWithKeyboard(); + + await userEvent.keyboard(' '); + + await expect.element(openItem()).toHaveFocus(); + expect(firstParagraphText()).toMatch(/^before #123/); + }); + + it('moves between its items with the arrow keys, Home and End', async () => { + await selectChipWithKeyboard(); + await userEvent.keyboard('{Enter}'); + await expect.element(openItem()).toHaveFocus(); + + await userEvent.keyboard('{ArrowRight}'); + await expect.element(sizeItem()).toHaveFocus(); + + await userEvent.keyboard('{End}'); + await expect.element(removeItem()).toHaveFocus(); + + await userEvent.keyboard('{ArrowRight}'); + await expect.element(openItem()).toHaveFocus(); + + await userEvent.keyboard('{ArrowLeft}'); + await expect.element(removeItem()).toHaveFocus(); + + await userEvent.keyboard('{Home}'); + await expect.element(openItem()).toHaveFocus(); + }); + + it('closes on Escape and hands the focus back to the editor with the chip still selected', async () => { + await selectChipWithKeyboard(); + await userEvent.keyboard('{Enter}'); + await expect.element(openItem()).toHaveFocus(); + + await userEvent.keyboard('{Escape}'); + + await expect.element(optionsMenu()).not.toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toHaveFocus(); + + await userEvent.keyboard('{Enter}'); + await expect.element(openItem()).toHaveFocus(); + }); + + it('closes on Tab and hands the focus back to the editor', async () => { + await selectChipWithKeyboard(); + await userEvent.keyboard('{Enter}'); + await expect.element(openItem()).toHaveFocus(); + + await userEvent.keyboard('{Tab}'); + + await expect.element(optionsMenu()).not.toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toHaveFocus(); + }); + + it('changes the size from the keyboard', async () => { + await selectChipWithKeyboard(); + await userEvent.keyboard('{Enter}{ArrowRight}'); + await expect.element(sizeItem()).toHaveAccessibleName('Change size: Regular'); + + await userEvent.keyboard('{Enter}'); + const regular = page.getByRole('menuitemradio', { name: 'Regular' }); + await expect.element(regular).toHaveFocus(); + await expect.element(regular).toHaveAttribute('aria-checked', 'true'); + + await userEvent.keyboard('{ArrowUp}{Enter}'); + + await expect.element(optionsMenu()).not.toBeInTheDocument(); + await expect.element(page.getByText('In Progress', { exact: true })).not.toBeInTheDocument(); + await expect.element(page.getByText('Fix login bug')).toBeVisible(); + await expect.element(page.getByRole('textbox')).toHaveFocus(); + }); + + it('closes only the size menu on Escape and puts the focus back on the item that opened it', async () => { + await selectChipWithKeyboard(); + await userEvent.keyboard('{Enter}{ArrowRight}{Enter}'); + await expect.element(page.getByRole('menuitemradio', { name: 'Regular' })).toHaveFocus(); + + await userEvent.keyboard('{Escape}'); + + await expect.element(page.getByTestId('size-menu')).not.toBeInTheDocument(); + await expect.element(sizeItem()).toHaveFocus(); + await expect.element(sizeItem()).toHaveAttribute('aria-expanded', 'false'); + }); + + it('removes the chip from the keyboard and hands the focus back to the editor', async () => { + await selectChipWithKeyboard(); + await userEvent.keyboard('{Enter}{End}{Enter}'); + + await expect.element(page.getByText('#123', { exact: true })).not.toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toHaveFocus(); + expect(liveRegion()?.textContent).toBe(''); + }); + + it('leaves the focus in the editor when a pointer opened it, and keeps it there on Escape', async () => { + renderEditor({ initialContent: chipDocument }); + await openInlineWorkPackagePopover(); + + await expect.element(page.getByRole('textbox')).toHaveFocus(); + + await userEvent.keyboard('{Escape}'); + await expect.element(optionsMenu()).not.toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toHaveFocus(); + }); + + it('only closes on Tab when a pointer opened it, instead of moving the focus on', async () => { + renderEditor({ initialContent: chipDocument }); + await openInlineWorkPackagePopover(); + + await userEvent.keyboard('{Tab}'); + + await expect.element(optionsMenu()).not.toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toHaveFocus(); + }); + + it('draws its own focus ring on the item in focus, whatever the host page does to focused buttons', async () => { + const hostStyles = document.createElement('style'); + hostStyles.textContent = ` + :focus-visible { outline: none !important; } + button:focus-visible { box-shadow: none; } + `; + document.head.appendChild(hostStyles); + onTestFinished(() => hostStyles.remove()); + + await selectChipWithKeyboard(); + await userEvent.keyboard('{Enter}'); + await expect.element(openItem()).toHaveFocus(); + expect(getComputedStyle(openItem().element()).boxShadow).toBe('rgb(100, 160, 255) 0px 0px 0px 2px inset'); + + await userEvent.keyboard('{ArrowRight}{Enter}'); + const regular = page.getByRole('menuitemradio', { name: 'Regular' }); + await expect.element(regular).toHaveFocus(); + expect(getComputedStyle(regular.element()).boxShadow).toBe('rgb(100, 160, 255) 0px 0px 0px 2px inset'); + }); + + it('works inside a shadow root', async () => { + let editor:any; + const { shadowRoot } = await renderEditorInShadowDom({ + initialContent: chipDocument, + onEditor: (created) => { editor = created; }, + }); + await expect.element(page.getByText('#123', { exact: true })).toBeVisible(); + // toHaveFocus compares against document.activeElement, which is only the shadow host. + const focused = () => shadowRoot.activeElement; + + putCaretBehindChip(editor); + await userEvent.keyboard('{ArrowLeft}{Enter}'); + + await expect.poll(focused).toBe(openItem().element()); + + await userEvent.keyboard('{Escape}'); + await expect.poll(focused).toBe(page.getByRole('textbox').element()); + }); +}); + +describe('Work package keyboard access - what a screen reader is told about an inline chip', () => { + it('exposes the identifier as the button that opens the options, next to the subject link', async () => { + renderEditor({ initialContent: chipDocument }); + const menuButton = page.getByRole('button', { name: 'Work package #123' }); + await expect.element(menuButton).toBeVisible(); + + await expect.element(menuButton).toHaveAttribute('aria-haspopup', 'menu'); + await expect.element(menuButton).toHaveAttribute('aria-expanded', 'false'); + + const subject = page.getByRole('link', { name: 'Fix login bug' }); + await expect.element(subject).toBeVisible(); + expect(subject.element().closest('[role="button"]')).toBeNull(); + + await openInlineWorkPackagePopover(); + await expect.element(menuButton).toHaveAttribute('aria-expanded', 'true'); + }); +}); + +describe('Work package keyboard access - a block card', () => { + it('is selected with the arrow key and opens its options on Enter', async () => { + renderEditor({ initialContent: blockDocument }); + await expect.element(page.getByTestId('block-card')).toBeVisible(); + + await userEvent.click(page.getByText('above')); + await userEvent.keyboard('{End}{ArrowDown}'); + + await expect.poll(() => liveRegion()?.textContent) + .toBe('Work package #123, Bug, In Progress, Fix login bug. Press Enter to open its options.'); + expect(formattingToolbarVisible()).toBe(false); + + await userEvent.keyboard('{Enter}'); + + await expect.element(openItem()).toHaveFocus(); + await expect.element(page.getByTestId('block-card')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + await expect.element(page.getByRole('textbox')).toHaveFocus(); + }); + + it('only closes on Tab when a pointer opened it, instead of nesting the block', async () => { + let editor:any; + renderEditor({ initialContent: blockDocument, onEditor: (created) => { editor = created; } }); + await openBlockCardPopover(); + + await userEvent.keyboard('{Tab}'); + + await expect.element(optionsMenu()).not.toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toHaveFocus(); + expect(editor.document.map((block:{ type:string }) => block.type)).toContain('openProjectWorkPackageBlock'); + }); + + it('exposes the identifier as the button that opens the options, next to the subject link', async () => { + renderEditor({ initialContent: blockDocument }); + const card = page.getByTestId('block-card'); + await expect.element(card).toBeVisible(); + + expect(card.element().getAttribute('role')).toBeNull(); + + const menuButton = card.getByRole('button', { name: 'Work package #123' }); + await expect.element(menuButton).toHaveAttribute('aria-haspopup', 'menu'); + + const subject = card.getByRole('link', { name: 'Fix login bug' }); + expect(subject.element().closest('[role="button"]')).toBeNull(); + }); +}); diff --git a/extensions/blocknote-extensions/test/lib/components/integration/workPackageUnavailable.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/workPackageUnavailable.browser.test.tsx index c030b2fc8f30..30ea8e073aeb 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/workPackageUnavailable.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/workPackageUnavailable.browser.test.tsx @@ -70,7 +70,7 @@ describe('Inline chip - unavailable work package', () => { ).not.toBeNull(); }); - it('renders only the icon with an aria-label and no native tooltip for size xxs', async () => { + it('renders only the icon, named for assistive tech and without a native tooltip, for size xxs', async () => { loadUnavailableWorkPackages(999); render( @@ -87,7 +87,8 @@ describe('Inline chip - unavailable work package', () => { expect(chip?.textContent).toBe(''); // The full message now lives in the preview, not a native tooltip. expect(chip?.getAttribute('title')).toBeNull(); - expect(chip?.getAttribute('aria-label')).toBe('Work package unavailable: no permission'); + const icon = page.getByRole('button', { name: 'Work package #999, Work package unavailable: no permission' }); + await expect.element(icon).toHaveAttribute('aria-haspopup', 'menu'); }); it('shows the unavailable card in the preview on hover for size xxs', async () => { @@ -104,7 +105,7 @@ describe('Inline chip - unavailable work package', () => { expect(document.querySelector('.op-bn-inline-wp .octicon-eye-closed')).not.toBeNull(); }); - await userEvent.hover(page.getByRole('img')); + await userEvent.hover(page.getByRole('button', { name: /Work package unavailable/ })); await expect.element(page.getByTestId('wp-preview')).toBeVisible(); await expect.element(page.getByText('Linked work package unavailable')).toBeVisible(); @@ -204,7 +205,7 @@ describe('Unavailable work package - preview indicator (touch)', () => { const indicator = page.getByTestId('wp-preview-indicator'); await expect.element(indicator).toBeVisible(); expect(document.querySelector('.op-bn-inline-wp')?.getAttribute('role')).toBeNull(); - const stateIcon = page.getByRole('button', { name: 'Work package unavailable: no permission' }); + const stateIcon = page.getByRole('button', { name: 'Work package #999, Work package unavailable: no permission' }); await expect.element(stateIcon).toBeVisible(); await userEvent.click(indicator); @@ -229,12 +230,11 @@ describe('Unavailable work package - options popover (BNE-112)', () => { await userEvent.click(page.getByTitle('Change size')); await expect.element(page.getByTestId('size-menu')).toBeVisible(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); - // xxs renders icon-only with the message exposed to assistive tech via aria-label - await vi.waitFor(() => { - expect(document.querySelector('.op-bn-inline-wp')?.getAttribute('aria-label')).toBe('Work package unavailable: no permission'); - }); + // xxs renders icon-only, the icon named for assistive tech in place of the message + await expect.element(page.getByRole('button', { name: 'Work package #999, Work package unavailable: no permission' })) + .toBeVisible(); }); it('inline chip: removes the chip from the document', async () => { diff --git a/extensions/blocknote-extensions/test/lib/components/integration/wpOptionsPopover.browser.test.tsx b/extensions/blocknote-extensions/test/lib/components/integration/wpOptionsPopover.browser.test.tsx index 06f66985d013..36dd68caa6a8 100644 --- a/extensions/blocknote-extensions/test/lib/components/integration/wpOptionsPopover.browser.test.tsx +++ b/extensions/blocknote-extensions/test/lib/components/integration/wpOptionsPopover.browser.test.tsx @@ -11,20 +11,13 @@ import { openInlineWorkPackagePopover, openBlockCardPopover, convertToCompactCard, + formattingToolbarVisible, } from '../../../helpers/editorHelpers'; afterEach(() => { cleanup(); }); -// BlockNote leaves an empty display:none toolbar container in the DOM when -// closed, so presence alone is not enough - only a rendered one counts. -const formattingToolbarVisible = () => - Array.from(document.querySelectorAll('[class*="formatting-toolbar"]')).some((el) => { - const he = el as HTMLElement; - return getComputedStyle(he).display !== 'none' && he.childElementCount > 0; - }); - function ChipWrapper({ initialSize, wpid = '123' }:{ initialSize:string; wpid?:string; @@ -79,7 +72,7 @@ describe('Inline chip size transitions (user-visible content)', () => { await expect.element(page.getByText('In Progress')).not.toBeInTheDocument(); await openSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Compact', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact', exact: true })); await expect.element(page.getByTestId('op-bn-work-package--type')).toBeVisible(); await expect.element(page.getByText('Fix login bug')).toBeVisible(); @@ -93,7 +86,7 @@ describe('Inline chip size transitions (user-visible content)', () => { await expect.element(page.getByText('In Progress')).not.toBeInTheDocument(); await openSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Regular', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Regular', exact: true })); await expect.element(page.getByText('In Progress')).toBeVisible(); }); @@ -107,7 +100,7 @@ describe('Inline chip size transitions (user-visible content)', () => { await expect.element(page.getByText('Fix login bug')).toBeVisible(); await openSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Tiny', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Tiny', exact: true })); await expect.element(page.getByText('#123')).toBeVisible(); await expect.element(page.getByTestId('op-bn-work-package--type')).not.toBeInTheDocument(); @@ -468,7 +461,7 @@ describe('Inline chip popover UX', () => { render(); await waitForResolvedChip(); await openSizeMenu(); - await userEvent.click(page.getByRole('button', { name: 'Compact', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact', exact: true })); await expect.element(page.getByTestId('size-menu')).not.toBeInTheDocument(); }); @@ -490,7 +483,7 @@ describe('Inline chip popover UX', () => { 'Regular', 'Compact card', ]) { - await expect.element(page.getByRole('button', { name: label, exact: true })).toBeVisible(); + await expect.element(page.getByRole('menuitemradio', { name: label, exact: true })).toBeVisible(); } }); }); @@ -513,7 +506,7 @@ describe('Options popover coexists with the editor', () => { await openInlineWorkPackagePopover(); await userEvent.click(page.getByTitle('Change size')); - await userEvent.click(page.getByRole('button', { name: 'Compact', exact: true })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'Compact', exact: true })); expect(formattingToolbarVisible()).toBe(false); });