diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce60495df..4a339f8901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,9 @@ and this project adheres to - ♻️(frontend) redirect to doc after duplicate #1175 - 🔧(project) change env.d system by using local files #1200 - ⚡️(frontend) improve tree stability #1207 -- ⚡️(frontend) improve accessibility #1232 +- ⚡️(frontend) improve accessibility + - #1232 + - #1251 - 🛂(frontend) block drag n drop when not desktop #1239 ### Fixed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor-heading-accessibility.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor-heading-accessibility.spec.ts new file mode 100644 index 0000000000..fa4d1d3ecd --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor-heading-accessibility.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Doc Editor - Heading Accessibility', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('should filter heading options progressively (h1 -> h2 -> h3)', async ({ + page, + }) => { + await page.getByRole('button', { name: 'Nouveau doc' }).click(); + + await page.waitForURL('**/docs/**', { + timeout: 10000, + waitUntil: 'domcontentloaded', + }); + + const input = page.getByLabel('doc title input'); + await input.fill('heading-accessibility-test'); + await input.blur(); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + + await page.keyboard.type('/'); + await expect(page.getByText('Titre 1')).toBeVisible(); + await expect(page.getByText('Titre 2')).toBeHidden(); + await expect(page.getByText('Titre 3')).toBeHidden(); + + await page.getByText('Titre 1').click(); + await page.keyboard.type('Main Title'); + await page.keyboard.press('Enter'); + + await editor.click(); + await page.keyboard.type('/'); + + await expect(page.getByText('Titre 1')).toBeHidden(); + await expect(page.getByText('Titre 2')).toBeVisible(); + await expect(page.getByText('Titre 3')).toBeHidden(); + + await page.getByText('Titre 2').click(); + await page.keyboard.type('Sub Title'); + await page.keyboard.press('Enter'); + + await editor.click(); + await page.keyboard.type('/'); + + await expect(page.getByText('Titre 1')).toBeHidden(); + await expect(page.getByText('Titre 2')).toBeVisible(); + await expect(page.getByText('Titre 3')).toBeVisible(); + + await page.getByText('Titre 3').click(); + await page.keyboard.type('Sub Sub Title'); + await page.keyboard.press('Enter'); + + await editor.click(); + await page.keyboard.type('/'); + + await expect(page.getByText('Titre 1')).toBeHidden(); + await expect(page.getByText('Titre 2')).toBeHidden(); + await expect(page.getByText('Titre 3')).toBeVisible(); + + await page.getByText('Titre 3').click(); + await page.keyboard.type('Another Sub Sub Title'); + await page.keyboard.press('Enter'); + + await editor.click(); + await page.keyboard.type('/'); + + await expect(page.getByText('Titre 1')).toBeHidden(); + await expect(page.getByText('Titre 2')).toBeHidden(); + await expect(page.getByText('Titre 3')).toBeVisible(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx index 4e8c6e3091..9d41cff830 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -9,6 +9,7 @@ import { import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useHeadingAccessibilityFilter } from '../hook'; import { DocsBlockSchema, DocsInlineContentSchema, @@ -34,6 +35,7 @@ export const BlockNoteSuggestionMenu = () => { const { t } = useTranslation(); const basicBlocksName = useDictionary().slash_menu.page_break.group; const getInterlinkingMenuItems = useGetInterlinkingMenuItems(); + const { filterHeadingItemsByAccessibility } = useHeadingAccessibilityFilter(); const getSlashMenuItems = useMemo(() => { // We insert it after the "Code Block" item to have the interlinking block displayed after the basic blocks @@ -47,11 +49,16 @@ export const BlockNoteSuggestionMenu = () => { ...defaultMenu.slice(index + 1), ]; + const filteredMenuItems = filterHeadingItemsByAccessibility( + newSlashMenuItems, + editor, + ); + return async (query: string) => Promise.resolve( filterSuggestionItems( combineByGroup( - newSlashMenuItems, + filteredMenuItems, getCalloutReactSlashMenuItems(editor, t, basicBlocksName), getMultiColumnSlashMenuItems?.(editor) || [], getPageBreakReactSlashMenuItems(editor), @@ -60,7 +67,13 @@ export const BlockNoteSuggestionMenu = () => { query, ), ); - }, [basicBlocksName, editor, getInterlinkingMenuItems, t]); + }, [ + basicBlocksName, + editor, + getInterlinkingMenuItems, + t, + filterHeadingItemsByAccessibility, + ]); return ( { + // function to extract heading level from menu item + const getHeadingLevel = ( + item: ReturnType[0], + ): number => { + const title = item.title?.toLowerCase() || ''; + const aliases = item.aliases || []; + const HEADING_2 = 'heading 2'; + const HEADING_3 = 'heading 3'; + const TITLE_2 = 'titre 2'; + const TITLE_3 = 'titre 3'; + + if ( + title.includes(HEADING_2) || + title.includes(TITLE_2) || + aliases.some( + (alias: string) => alias.includes(HEADING_2) || alias.includes(TITLE_2), + ) + ) { + return 2; + } + + if ( + title.includes(HEADING_3) || + title.includes(TITLE_3) || + aliases.some( + (alias: string) => alias.includes(HEADING_3) || alias.includes(TITLE_3), + ) + ) { + return 3; + } + + return 1; + }; + + // function to check if item is a heading + const isHeadingItem = ( + item: ReturnType[0], + ): boolean => { + return item.onItemClick?.toString().includes('heading'); + }; + + const filterHeadingItemsByAccessibility = ( + items: ReturnType, + editor: DocsBlockNoteEditor, + ) => { + const existingLevels = editor.document + .filter((block) => block.type === 'heading') + .map((block) => (block.props as { level: number }).level); + + const hasH1 = existingLevels.includes(1); + + if (existingLevels.length === 0) { + return items.filter( + (item) => !isHeadingItem(item) || getHeadingLevel(item) === 1, + ); + } + + const maxLevel = Math.max(...existingLevels); + const minLevel = Math.min(...existingLevels); + + return items.filter((item) => { + if (!isHeadingItem(item)) { + return true; + } + + const headingLevel = getHeadingLevel(item); + + // Never allow h1 if one already exists >> accessibility tells that we can only have one h1 per document + if (headingLevel === 1 && hasH1) { + return false; + } + + return ( + headingLevel === maxLevel || + headingLevel === maxLevel + 1 || + (headingLevel === minLevel - 1 && minLevel > 1) + ); + }); + }; + + return { filterHeadingItemsByAccessibility }; +};