️(frontend) fix heading insertion via useHeadingAccessibilityFilter

enforces contiguous heading levels to ensure accessibility compliance

Signed-off-by: Cyril <[email protected]>
This commit is contained in:
Cyril
2025-07-31 17:47:34 +02:00
parent f5f9d8a877
commit 0c8bf4013a
3 changed files with 103 additions and 2 deletions
@@ -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 (
<SuggestionMenuController
@@ -2,3 +2,4 @@ export * from './useHeadings';
export * from './useSaveDoc';
export * from './useShortcuts';
export * from './useUploadFile';
export * from './useHeadingAccessibilityFilter';
@@ -0,0 +1,87 @@
import { getDefaultReactSlashMenuItems } from '@blocknote/react';
import { DocsBlockNoteEditor } from '../types';
export const useHeadingAccessibilityFilter = () => {
// function to extract heading level from menu item
const getHeadingLevel = (
item: ReturnType<typeof getDefaultReactSlashMenuItems>[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<typeof getDefaultReactSlashMenuItems>[0],
): boolean => {
return item.onItemClick?.toString().includes('heading');
};
const filterHeadingItemsByAccessibility = (
items: ReturnType<typeof getDefaultReactSlashMenuItems>,
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 };
};