import { useMemo, useEffect, useCallback } from 'react' import { defineMessages, useIntl } from 'react-intl' import { useGridContext, useGridSelector } from '../../context/grid-context' import type { GridRowId } from '../../types' import type { GridState } from '../../state' import type { StoreSelectors } from '../../state/selectors' import { useGridLiveRegion } from '../../context/grid-live-region-context' import type { Announcements } from '@dnd-kit/core' import { usePrevious } from '@planview/pv-utilities' import type { CollisionDetails } from './collision' import { DRAG_TYPE_HEADER_GROUP_CELL } from './constants' const messages = defineMessages({ //Initial instructions screenReaderInstructions: { id: 'pvds.grid.a11y.screenReaderInstructions', defaultMessage: 'To pick up a row, press the space bar. While dragging, use the arrow keys to update the row position in the grid. Press space again to drop the row in its new position, or press escape to cancel.', description: 'Screen reader instructions on how to drag and drop rows', }, rowDragStart: { id: 'pvds.grid.a11y.rowDragStart', defaultMessage: 'Row {row} picked up.', description: 'Screen reader announcement when row was picked up', }, rowDragStartMoreRowsDragging: { id: 'pvds.grid.a11y.rowDragStartMoreRowsDragging', defaultMessage: 'Dragging {count, number} rows.', description: 'Screen reader announcement to be appended to drag start operation announcement when multiple rows are dragged', }, rowDragMoveRootBetween: { id: 'pvds.grid.a11y.rowDragMoveRootBetween', defaultMessage: '{count, plural, one {Row} other {Rows}} moved into position after row {sibling}.', description: 'Screen reader announcement when row was moved between rows after sibling', }, rowDragMoveParentBetween: { id: 'pvds.grid.a11y.rowDragMoveParentBetween', defaultMessage: '{count, plural, one {Row} other {Rows}} moved into position after row {sibling} under {parent}.', description: 'Screen reader announcement when row was moved between rows after sibling under a parent', }, rowDragMoveRootFirstBetween: { id: 'pvds.grid.a11y.rowDragMoveRootFirstBetween', defaultMessage: '{count, plural, one {Row} other {Rows}} moved into first position.', description: 'Screen reader announcement when row was moved between rows in first position', }, rowDragMoveParentFirstBetween: { id: 'pvds.grid.a11y.rowDragMoveParentFirstBetween', defaultMessage: '{count, plural, one {Row} other {Rows}} moved into first position under {parent}.', description: 'Screen reader announcement when row was moved between rows in first position under a parent row', }, rowDragMoveOverParent: { id: 'pvds.grid.a11y.rowDragMoveOverParent', defaultMessage: '{count, plural, one {Row} other {Rows}} moved over {parent}.', description: 'Screen reader announcement when row was moved over row', }, rowDragEnd: { id: 'pvds.grid.a11y.rowDragEnd', defaultMessage: '{count, plural, one {Row} other {Rows}} dropped.', description: 'Screen reader announcement when row dropped into new position', }, rowDragCancel: { id: 'pvds.grid.a11y.rowDragCancel', defaultMessage: 'Dragging cancelled.', description: 'Screen reader announcement when drag operation was cancelled', }, dropNotAllowed: { id: 'pvds.grid.a11y.dropNotAllowed', defaultMessage: 'Drop is not allowed here.', description: 'Screen reader announcement to be appended to drag operation announcement when drop is prohibited', }, columnDragStart: { id: 'pvds.grid.a11y.columnDragStart', defaultMessage: 'Column {column} picked up.', description: 'Screen reader announcement when column was picked up', }, columnGroupDragStart: { id: 'pvds.grid.a11y.columnGroupDragStart', defaultMessage: 'Column group {column} picked up.', description: 'Screen reader announcement when column group was picked up', }, columnDragEnd: { id: 'pvds.grid.a11y.columnDragEnd', defaultMessage: 'Column {column} dropped.', description: 'Screen reader announcement when column dropped into new position', }, columnGroupDragEnd: { id: 'pvds.grid.a11y.columnGroupDragEnd', defaultMessage: 'Column group {column} dropped.', description: 'Screen reader announcement when column group dropped into new position', }, columnDragCancel: { id: 'pvds.grid.a11y.columnDragCancel', defaultMessage: 'Dragging cancelled.', description: 'Screen reader announcement when column (or column group) drag operation was cancelled', }, }) const Noop: Announcements = { onDragStart: () => '', onDragOver: () => '', onDragMove: () => '', onDragEnd: () => '', onDragCancel: () => '', } const getRowLabel = ( id: GridRowId, state: GridState, selectors: StoreSelectors ): string => { if (id == null) { return '' } const previewColumnId = selectors.selectPreviewColumnId(state) const columnAll = previewColumnId ? selectors.selectColumn(state, previewColumnId) : null const rowData = selectors.selectRow(state, id) const rowMeta = selectors.selectRowMeta(state, id) const column = columnAll?.data if (!column || !previewColumnId) { return '' } const value = column.cell?.value ? column.cell.value({ columnId: previewColumnId, row: rowData, rowMeta: rowMeta, }) : rowData?.[previewColumnId] const label = column.cell?.label ? column.cell?.label({ columnId: previewColumnId, row: rowData, rowMeta: rowMeta, value, }) : value == null ? '' : value.toString() return label } const useColumnDragAnnouncements = () => { const { formatMessage } = useIntl() const announcement: Announcements = useMemo( () => ({ ...Noop, onDragCancel: () => formatMessage(messages.columnDragCancel), onDragStart: (e) => formatMessage( e.active.data.current?.type === DRAG_TYPE_HEADER_GROUP_CELL ? messages.columnGroupDragStart : messages.columnDragStart, { column: e.active.data.current?.column?.label ?? '' } ), onDragEnd: (e) => formatMessage( e.active.data.current?.type === DRAG_TYPE_HEADER_GROUP_CELL ? messages.columnGroupDragEnd : messages.columnDragEnd, { column: e.active.data.current?.column?.label ?? '' } ), }), [formatMessage] ) return announcement } export const useAnnouncements = () => { const { formatMessage } = useIntl() const { setAnnouncement } = useGridLiveRegion() const grid = useGridContext() const { area } = useGridSelector(grid.selectors.selectCurrentFocus) const columnAnnouncements = useColumnDragAnnouncements() const isDraggingActive = useGridSelector(grid.selectors.selectDragIsActive) const collisionDetails = useGridSelector( grid.selectors.selectDragCollisionDetails ) const previousDraggingActive = usePrevious(isDraggingActive) const announceDragStart = useCallback(() => { const state = grid.getState() const draggingRowIds = grid.selectors.selectDraggingRowIds(state) const id = [...draggingRowIds][0] const label = getRowLabel(id, state, grid.selectors) let announcement = formatMessage(messages.rowDragStart, { row: label, }) if (draggingRowIds.size > 1) { announcement = `${announcement} ${formatMessage( messages.rowDragStartMoreRowsDragging, { count: draggingRowIds.size } )}` } setAnnouncement(announcement) }, [formatMessage, grid, setAnnouncement]) const announceDragMove = useCallback( (collisionDetails: CollisionDetails) => { let returnString = '' const state = grid.getState() const draggingRowIds = grid.selectors.selectDraggingRowIds(state) const isDropAllowed = grid.selectors.selectDropIsAllowed(state) const errorMessage = grid.selectors.selectDragMessage(state) const { parentId, operation, prevInsertId } = collisionDetails const parent = parentId ? getRowLabel(parentId, state, grid.selectors) : '' const sibling = prevInsertId ? getRowLabel(prevInsertId, state, grid.selectors) : '' const count = draggingRowIds.size if (operation === 'between') { if (parentId) { //We are over parent if (sibling) { //we have item before returnString = formatMessage( messages.rowDragMoveParentBetween, { count, sibling, parent, } ) } else { //We are at first place returnString = returnString = formatMessage( messages.rowDragMoveParentFirstBetween, { count, parent, } ) } } else { //We are over root if (sibling) { //we have item before returnString = formatMessage( messages.rowDragMoveRootBetween, { count, sibling, } ) } else { //We are at first place returnString = formatMessage( messages.rowDragMoveRootFirstBetween, { count, } ) } } } else { returnString = formatMessage(messages.rowDragMoveOverParent, { count, parent, }) } if (!isDropAllowed) { returnString = `${returnString} ${formatMessage( messages.dropNotAllowed )}${errorMessage ? ` ${errorMessage}` : ''}` } setAnnouncement(returnString) }, [formatMessage, grid, setAnnouncement] ) const announceDragEnd = useCallback( (recentlyDroppedRowIds: Set) => { setAnnouncement( formatMessage(messages.rowDragEnd, { count: recentlyDroppedRowIds.size, }) ) }, [formatMessage, setAnnouncement] ) const announceDragCancel = useCallback(() => { setAnnouncement(formatMessage(messages.rowDragCancel)) }, [formatMessage, setAnnouncement]) useEffect(() => { const state = grid.getState() const hasMoved = grid.selectors.selectDragMoveReceived(state) if (isDraggingActive && !previousDraggingActive) { announceDragStart() } else if (!isDraggingActive && previousDraggingActive) { const recentlyDroppedRowIds = grid.selectors.selectRecentlyDroppedRowIds(state) if (recentlyDroppedRowIds.size) { announceDragEnd(recentlyDroppedRowIds) } else { announceDragCancel() } } else if (hasMoved) { announceDragMove(collisionDetails) } }, [ announceDragCancel, announceDragEnd, announceDragStart, announceDragMove, collisionDetails, grid, isDraggingActive, previousDraggingActive, ]) return useMemo( () => ({ announcements: area === 'header' ? columnAnnouncements : Noop, screenReaderInstructions: { draggable: isDraggingActive ? '' : area === 'header' ? '' : formatMessage(messages.screenReaderInstructions), }, }), [area, columnAnnouncements, formatMessage, isDraggingActive] ) }