diff --git a/libs/@guardian/react-crossword/src/@types/crossword.ts b/libs/@guardian/react-crossword/src/@types/crossword.ts index e6ad06a07..54018baf1 100644 --- a/libs/@guardian/react-crossword/src/@types/crossword.ts +++ b/libs/@guardian/react-crossword/src/@types/crossword.ts @@ -13,6 +13,9 @@ export type Cell = Coords & { /** Array of entries that this solution is part of */ group?: CrosswordEntry['group']; + /** The cell's description */ + description?: string; + /** The cell's solution */ solution?: string; }; diff --git a/libs/@guardian/react-crossword/src/components/Grid.tsx b/libs/@guardian/react-crossword/src/components/Grid.tsx index 1585ae8ca..d66c5b331 100644 --- a/libs/@guardian/react-crossword/src/components/Grid.tsx +++ b/libs/@guardian/react-crossword/src/components/Grid.tsx @@ -3,11 +3,9 @@ import { isUndefined } from '@guardian/libs'; import { textSans12 } from '@guardian/source/foundations'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import type { FocusEvent, KeyboardEvent } from 'react'; -import type { CAPIEntry } from '../@types/CAPI'; import type { Cell as CellType, Coords, - Entries, Separator, Theme, } from '../@types/crossword'; @@ -19,52 +17,11 @@ import { useProgress } from '../context/Progress'; import { useTheme } from '../context/Theme'; import { useCheatMode } from '../hooks/useCheatMode'; import { useUpdateCell } from '../hooks/useUpdateCell'; -import { formatClueForScreenReader } from '../utils/formatClueForScreenReader'; import { keyDownRegex } from '../utils/keydownRegex'; import { Cell } from './Cell'; const noop = () => {}; -const getCellDescription = (cell: CellType, entries: Entries) => { - const cellEntryIds = cell.group ?? []; - const cellRelevantEntryId = - cell.group?.length === 1 - ? cell.group[0] - : cellEntryIds.find((id) => id.endsWith('across')); - if (isUndefined(cellRelevantEntryId)) { - return 'Blank cell.'; - } - const additionalEntries = cellEntryIds - .filter((id) => !id.endsWith('across') && id !== cellRelevantEntryId) - .map((id) => entries.get(id)) - .filter((entry) => !isUndefined(entry)); - const relevantEntry = entries.get(cellRelevantEntryId); - - return ( - `` + - // ('Letter 2 of 4-across: Life is in a mess (5 letters).) | ('Blank cell.') - `${relevantEntry ? `${getReadableLabelForCellAndEntry({ entry: relevantEntry, cell: cell })}. ` : 'Blank. '}` + - // (Also, letter 1 of 5-down Life is always in a mess (2 letters).) - `${additionalEntries.map((entry) => getReadableLabelForCellAndEntry({ entry, cell: cell, additionalEntry: true })).join('. ')}` - ); -}; - -const getReadableLabelForCellAndEntry = ({ - entry, - cell, - additionalEntry = false, -}: { - entry: CAPIEntry; - cell: CellType; - additionalEntry?: boolean; -}): string => { - const cellPosition = - entry.direction === 'across' - ? String(cell.x + 1 - entry.position.x) - : String(cell.y + 1 - entry.position.y); - return `${additionalEntry ? 'Also, letter' : 'Letter'} ${cellPosition} of ${entry.length}. ${entry.id}. ${formatClueForScreenReader(entry.clue)}`; -}; - const getCellPosition = ( index: number, { gridCellSize, gridGutterSize }: Theme, @@ -508,7 +465,7 @@ export const Grid = () => { } tabIndex={isCurrentCell ? 0 : -1} aria-label="Crossword cell" - aria-description={getCellDescription(cell, entries)} + aria-description={cell.description ?? ''} css={css` position: absolute; top: 0; diff --git a/libs/@guardian/react-crossword/src/utils/getCellDescription.ts b/libs/@guardian/react-crossword/src/utils/getCellDescription.ts new file mode 100644 index 000000000..88a2fd2d6 --- /dev/null +++ b/libs/@guardian/react-crossword/src/utils/getCellDescription.ts @@ -0,0 +1,44 @@ +import { isUndefined } from '@guardian/libs'; +import type { CAPIEntry } from '../@types/CAPI'; +import type { Cell, Entries } from '../@types/crossword'; +import { formatClueForScreenReader } from './formatClueForScreenReader'; + +export const getCellDescription = (cell: Cell, entries: Entries) => { + const cellEntryIds = cell.group ?? []; + const cellRelevantEntryId = + cell.group?.length === 1 + ? cell.group[0] + : cellEntryIds.find((id) => id.endsWith('across')); + if (isUndefined(cellRelevantEntryId)) { + return 'Blank cell.'; + } + const additionalEntries = cellEntryIds + .filter((id) => !id.endsWith('across') && id !== cellRelevantEntryId) + .map((id) => entries.get(id)) + .filter((entry) => !isUndefined(entry)); + const relevantEntry = entries.get(cellRelevantEntryId); + + return ( + `` + + // ('Letter 2 of 4-across: Life is in a mess (5 letters).) | ('Blank cell.') + `${relevantEntry ? `${getReadableLabelForCellAndEntry({ entry: relevantEntry, cell: cell })}. ` : 'Blank. '}` + + // (Also, letter 1 of 5-down Life is always in a mess (2 letters).) + `${additionalEntries.map((entry) => getReadableLabelForCellAndEntry({ entry, cell: cell, additionalEntry: true })).join('. ')}` + ); +}; + +const getReadableLabelForCellAndEntry = ({ + entry, + cell, + additionalEntry = false, +}: { + entry: CAPIEntry; + cell: Cell; + additionalEntry?: boolean; +}): string => { + const cellPosition = + entry.direction === 'across' + ? String(cell.x + 1 - entry.position.x) + : String(cell.y + 1 - entry.position.y); + return `${additionalEntry ? 'Also, letter' : 'Letter'} ${cellPosition} of ${entry.length}. ${entry.id}. ${formatClueForScreenReader(entry.clue)}`; +}; diff --git a/libs/@guardian/react-crossword/src/utils/parseCrosswordData.ts b/libs/@guardian/react-crossword/src/utils/parseCrosswordData.ts index 24c26f378..5f0fb2531 100644 --- a/libs/@guardian/react-crossword/src/utils/parseCrosswordData.ts +++ b/libs/@guardian/react-crossword/src/utils/parseCrosswordData.ts @@ -7,6 +7,7 @@ import type { Entries, Separators, } from '../@types/crossword'; +import { getCellDescription } from './getCellDescription'; /** * Takes the crossword data from the CAPI and returns some things we can use. @@ -98,6 +99,12 @@ export const parseCrosswordData = (data: { } } + // Map over cells and add descriptions. + // We need the entries map for this so have to do it after the loop + cells.forEach((cell) => { + cell.description = getCellDescription(cell, entries); + }); + return { cells, entries,