Skip to content

Commit 0c8bf40

Browse files
committed
⚡️(frontend) fix heading insertion via useHeadingAccessibilityFilter
enforces contiguous heading levels to ensure accessibility compliance Signed-off-by: Cyril <[email protected]>
1 parent f5f9d8a commit 0c8bf40

File tree

3 files changed

+103
-2
lines changed

3 files changed

+103
-2
lines changed

src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import React, { useMemo } from 'react';
1010
import { useTranslation } from 'react-i18next';
1111

12+
import { useHeadingAccessibilityFilter } from '../hook';
1213
import {
1314
DocsBlockSchema,
1415
DocsInlineContentSchema,
@@ -34,6 +35,7 @@ export const BlockNoteSuggestionMenu = () => {
3435
const { t } = useTranslation();
3536
const basicBlocksName = useDictionary().slash_menu.page_break.group;
3637
const getInterlinkingMenuItems = useGetInterlinkingMenuItems();
38+
const { filterHeadingItemsByAccessibility } = useHeadingAccessibilityFilter();
3739

3840
const getSlashMenuItems = useMemo(() => {
3941
// 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 = () => {
4749
...defaultMenu.slice(index + 1),
4850
];
4951

52+
const filteredMenuItems = filterHeadingItemsByAccessibility(
53+
newSlashMenuItems,
54+
editor,
55+
);
56+
5057
return async (query: string) =>
5158
Promise.resolve(
5259
filterSuggestionItems(
5360
combineByGroup(
54-
newSlashMenuItems,
61+
filteredMenuItems,
5562
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
5663
getMultiColumnSlashMenuItems?.(editor) || [],
5764
getPageBreakReactSlashMenuItems(editor),
@@ -60,7 +67,13 @@ export const BlockNoteSuggestionMenu = () => {
6067
query,
6168
),
6269
);
63-
}, [basicBlocksName, editor, getInterlinkingMenuItems, t]);
70+
}, [
71+
basicBlocksName,
72+
editor,
73+
getInterlinkingMenuItems,
74+
t,
75+
filterHeadingItemsByAccessibility,
76+
]);
6477

6578
return (
6679
<SuggestionMenuController

src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './useHeadings';
22
export * from './useSaveDoc';
33
export * from './useShortcuts';
44
export * from './useUploadFile';
5+
export * from './useHeadingAccessibilityFilter';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { getDefaultReactSlashMenuItems } from '@blocknote/react';
2+
3+
import { DocsBlockNoteEditor } from '../types';
4+
5+
export const useHeadingAccessibilityFilter = () => {
6+
// function to extract heading level from menu item
7+
const getHeadingLevel = (
8+
item: ReturnType<typeof getDefaultReactSlashMenuItems>[0],
9+
): number => {
10+
const title = item.title?.toLowerCase() || '';
11+
const aliases = item.aliases || [];
12+
const HEADING_2 = 'heading 2';
13+
const HEADING_3 = 'heading 3';
14+
const TITLE_2 = 'titre 2';
15+
const TITLE_3 = 'titre 3';
16+
17+
if (
18+
title.includes(HEADING_2) ||
19+
title.includes(TITLE_2) ||
20+
aliases.some(
21+
(alias: string) => alias.includes(HEADING_2) || alias.includes(TITLE_2),
22+
)
23+
) {
24+
return 2;
25+
}
26+
27+
if (
28+
title.includes(HEADING_3) ||
29+
title.includes(TITLE_3) ||
30+
aliases.some(
31+
(alias: string) => alias.includes(HEADING_3) || alias.includes(TITLE_3),
32+
)
33+
) {
34+
return 3;
35+
}
36+
37+
return 1;
38+
};
39+
40+
// function to check if item is a heading
41+
const isHeadingItem = (
42+
item: ReturnType<typeof getDefaultReactSlashMenuItems>[0],
43+
): boolean => {
44+
return item.onItemClick?.toString().includes('heading');
45+
};
46+
47+
const filterHeadingItemsByAccessibility = (
48+
items: ReturnType<typeof getDefaultReactSlashMenuItems>,
49+
editor: DocsBlockNoteEditor,
50+
) => {
51+
const existingLevels = editor.document
52+
.filter((block) => block.type === 'heading')
53+
.map((block) => (block.props as { level: number }).level);
54+
55+
const hasH1 = existingLevels.includes(1);
56+
57+
if (existingLevels.length === 0) {
58+
return items.filter(
59+
(item) => !isHeadingItem(item) || getHeadingLevel(item) === 1,
60+
);
61+
}
62+
63+
const maxLevel = Math.max(...existingLevels);
64+
const minLevel = Math.min(...existingLevels);
65+
66+
return items.filter((item) => {
67+
if (!isHeadingItem(item)) {
68+
return true;
69+
}
70+
71+
const headingLevel = getHeadingLevel(item);
72+
73+
// Never allow h1 if one already exists >> accessibility tells that we can only have one h1 per document
74+
if (headingLevel === 1 && hasH1) {
75+
return false;
76+
}
77+
78+
return (
79+
headingLevel === maxLevel ||
80+
headingLevel === maxLevel + 1 ||
81+
(headingLevel === minLevel - 1 && minLevel > 1)
82+
);
83+
});
84+
};
85+
86+
return { filterHeadingItemsByAccessibility };
87+
};

0 commit comments

Comments
 (0)