Compare commits

..

1 Commits

Author SHA1 Message Date
Cyril b7d0ba7bdd ️(frontend) fix sidebar resize handle for screen readers
Expose the handle as a slider so arrow keys work with NVDA
2026-03-24 16:55:31 +01:00
33 changed files with 2248 additions and 2774 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ and this project adheres to
### Changed
- 💄(frontend) improve comments highlights #1961
- ♿️(frontend) fix empty heading before section titles in HTML export #2125
♿️(frontend) fix sidebar resize handle for screen readers #2122
## [v4.8.3] - 2026-03-23
-3
View File
@@ -60,13 +60,10 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@react-pdf/renderer",
"fetch-mock",
"node",
"node-fetch",
"react-resizable-panels",
"stylelint",
"stylelint-config-standard",
"workbox-webpack-plugin"
]
}
@@ -47,9 +47,9 @@ test.describe('Doc AI feature', () => {
await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Anything').selectText();
await expect(
page.locator('button[data-test="convertMarkdown"]'),
).toHaveCount(1);
expect(
await page.locator('button[data-test="convertMarkdown"]').count(),
).toBe(1);
await expect(
page.getByRole('button', { name: config.selector, exact: true }),
).toBeHidden();
@@ -155,11 +155,13 @@ test.describe('Doc Editor', () => {
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocket = await page.waitForEvent('websocket', (webSocket) => {
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:4444/collaboration/ws/?room=');
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
@@ -576,10 +578,12 @@ test.describe('Doc Editor', () => {
await page.reload();
responseCanEdit = await page.waitForResponse(
responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
@@ -236,7 +236,7 @@ test.describe('Doc Header', () => {
hasText: randomDoc,
});
await expect(row).toHaveCount(0);
expect(await row.count()).toBe(0);
});
test('it checks the options available if administrator', async ({ page }) => {
@@ -273,7 +273,7 @@ test.describe('Doc Header', () => {
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -348,7 +348,7 @@ test.describe('Doc Header', () => {
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -418,7 +418,7 @@ test.describe('Doc Header', () => {
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -177,5 +177,5 @@ const dragAndDropFiles = async (
return dt;
}, filesData);
await page.locator(selector).dispatchEvent('drop', { dataTransfer });
await page.dispatchEvent(selector, 'drop', { dataTransfer });
};
@@ -43,12 +43,15 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible();
// Check the position of the sub pages
const allSubPageItems = docTree.getByTestId(/^doc-sub-page-item/);
await expect(allSubPageItems).toHaveCount(2);
const allSubPageItems = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
// Check that elements are in the correct order
await expect(allSubPageItems.nth(0).getByText('first move')).toBeVisible();
await expect(allSubPageItems.nth(1).getByText('second move')).toBeVisible();
await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
await expect(allSubPageItems[1].getByText('second move')).toBeVisible();
// Will move the first sub page to the second position
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
@@ -88,15 +91,17 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible();
// Check that elements are in the correct order
const allSubPageItemsAfterReload =
docTree.getByTestId(/^doc-sub-page-item/);
await expect(allSubPageItemsAfterReload).toHaveCount(2);
const allSubPageItemsAfterReload = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItemsAfterReload.length).toBe(2);
await expect(
allSubPageItemsAfterReload.nth(0).getByText('second move'),
allSubPageItemsAfterReload[0].getByText('second move'),
).toBeVisible();
await expect(
allSubPageItemsAfterReload.nth(1).getByText('first move'),
allSubPageItemsAfterReload[1].getByText('first move'),
).toBeVisible();
});
@@ -80,9 +80,9 @@ test.describe('Doc Version', () => {
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = panel.locator('.version-item');
await expect(items).toHaveCount(2);
await items.nth(1).click();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(2);
await items[1].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -90,7 +90,7 @@ test.describe('Doc Version', () => {
modal.locator('div[data-content-type="callout"]').first(),
).toBeHidden();
await items.nth(0).click();
await items[0].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeVisible();
@@ -101,7 +101,7 @@ test.describe('Doc Version', () => {
modal.getByText('It will create a second version'),
).toBeHidden();
await items.nth(1).click();
await items[1].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -3,11 +3,6 @@ import path from 'path';
import { Locator, Page, TestInfo, expect } from '@playwright/test';
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
/** Returns a locator for a menu item (handles both menuitem and menuitemradio roles) */
export const getMenuItem = (
context: Page | Locator,
@@ -18,6 +13,11 @@ export const getMenuItem = (
.getByRole('menuitem', { name, exact: options?.exact })
.or(context.getByRole('menuitemradio', { name, exact: options?.exact }));
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
export const CONFIG = {
AI_BOT: {
name: 'Docs AI',
+21 -20
View File
@@ -23,7 +23,7 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@ai-sdk/openai": "3.0.45",
"@ai-sdk/openai": "3.0.19",
"@blocknote/code-block": "0.47.1",
"@blocknote/core": "0.47.1",
"@blocknote/mantine": "0.47.1",
@@ -38,20 +38,20 @@
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.38",
"@fontsource-variable/material-symbols-outlined": "5.2.35",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.2.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.19.10",
"@gouvfr-lasuite/ui-kit": "0.19.6",
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "8.3.17",
"@mantine/hooks": "8.3.17",
"@mantine/core": "8.3.14",
"@mantine/hooks": "8.3.14",
"@react-aria/live-announcer": "3.4.4",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.43.0",
"@sentry/nextjs": "10.38.0",
"@tanstack/react-query": "5.90.21",
"@tiptap/extensions": "*",
"ai": "6.0.128",
"ai": "6.0.49",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -59,28 +59,28 @@
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.8.18",
"i18next": "25.8.12",
"i18next-browser-languagedetector": "8.2.1",
"idb": "8.0.3",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "16.1.7",
"posthog-js": "1.360.2",
"posthog-js": "1.347.2",
"react": "*",
"react-aria-components": "1.16.0",
"react-aria-components": "1.15.1",
"react-dom": "*",
"react-dropzone": "15.0.0",
"react-i18next": "16.5.8",
"react-intersection-observer": "10.0.3",
"react-i18next": "16.5.4",
"react-intersection-observer": "10.0.2",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.3.11",
"styled-components": "6.3.9",
"use-debounce": "10.1.0",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
"zod": "4.3.6",
"zustand": "5.0.12"
"zod": "3.25.28",
"zustand": "5.0.11"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
@@ -89,25 +89,26 @@
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.24",
"@types/lodash": "4.17.23",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "6.0.1",
"@vitejs/plugin-react": "5.1.4",
"cross-env": "10.1.0",
"dotenv": "17.3.1",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "29.0.0",
"jsdom": "28.1.0",
"node-fetch": "2.7.0",
"prettier": "3.8.1",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vitest": "4.1.0",
"webpack": "5.105.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.0.18",
"webpack": "5.105.2",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { toBase64 } from '@/utils/string';
import { isFirefox } from '@/utils/userAgent';
@@ -1,9 +1,4 @@
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/docs/doc-export/components/ModalExport', () => ({
ModalExport: vi.fn(),
}));
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
describe('useModuleExport', () => {
@@ -21,12 +16,12 @@ describe('useModuleExport', () => {
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toBeUndefined();
});
}, 15000);
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toHaveProperty('ModalExport');
});
}, 15000);
});
@@ -1,267 +0,0 @@
import { improveHtmlAccessibility } from '../utils_html';
const parse = (html: string): Document => {
const parser = new DOMParser();
return parser.parseFromString(html, 'text/html');
};
const bodyHtml = (doc: Document) =>
doc.body.innerHTML.replace(/\s+/g, ' ').trim();
describe('improveHtmlAccessibility', () => {
// Headings
describe('headings', () => {
it('converts heading blocks without inner h tag to semantic h1h6', () => {
const doc = parse(`
<div data-content-type="heading" data-level="3"><span>Title</span></div>
`);
improveHtmlAccessibility(doc, 'Doc');
const h3 = doc.querySelector('h3');
expect(h3).not.toBeNull();
expect(h3!.textContent.trim()).toBe('Title');
});
it('does not nest a second heading when BlockNote already outputs h1h6', () => {
const doc = parse(`
<div data-content-type="heading" data-level="2">
<h2 class="bn-inline-content">Section</h2>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
const h2 = doc.querySelectorAll('h2');
expect(h2).toHaveLength(1);
expect(h2[0].textContent).toBe('Section');
expect(h2[0].className).toBe('bn-inline-content');
});
it('uses data-level when inner heading tag has a different level', () => {
const doc = parse(`
<div data-content-type="heading" data-level="3">
<h2 class="bn-inline-content">Mismatch</h2>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
expect(doc.querySelector('h3')).not.toBeNull();
expect(doc.querySelector('h2')).toBeNull();
});
it('inserts an h1 with the document title when none exists', () => {
const doc = parse(`<p>Content</p>`);
improveHtmlAccessibility(doc, 'My Doc');
const h1 = doc.querySelector('h1');
expect(h1).not.toBeNull();
expect(h1!.id).toBe('doc-title');
expect(h1!.textContent).toBe('My Doc');
});
it('does not insert h1 when the document already has one', () => {
const doc = parse(`
<div data-content-type="heading" data-level="1"><span>Existing</span></div>
`);
improveHtmlAccessibility(doc, 'Ignored');
expect(doc.querySelectorAll('h1')).toHaveLength(1);
});
});
// Lists
describe('lists', () => {
it('converts bullet list items to ul > li', () => {
const doc = parse(`
<div class="bn-block-group">
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>A</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>B</span></div>
</div>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
const items = doc.querySelectorAll('ul > li');
expect(items).toHaveLength(2);
expect(items[0].textContent).toBe('A');
expect(items[1].textContent).toBe('B');
});
it('converts numbered list items to ol > li', () => {
const doc = parse(`
<div class="bn-block-group">
<div class="bn-block-outer">
<div data-content-type="numberedListItem"><span>1st</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="numberedListItem"><span>2nd</span></div>
</div>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
expect(doc.querySelectorAll('ol > li')).toHaveLength(2);
});
it('splits lists when a heading sits between them', () => {
const doc = parse(`
<div class="bn-block-group">
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>A</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>B</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="heading" data-level="2"><span>Next</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>C</span></div>
</div>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
const uls = doc.querySelectorAll('ul');
expect(uls).toHaveLength(2);
expect(uls[0].querySelectorAll('li')).toHaveLength(2);
expect(uls[1].querySelectorAll('li')).toHaveLength(1);
const html = bodyHtml(doc);
expect(html.indexOf('<ul')).toBeLessThan(html.indexOf('<h2'));
expect(html.indexOf('<h2')).toBeLessThan(
html.indexOf('<ul', html.indexOf('<ul') + 1),
);
});
it('keeps consecutive same-type items in a single list', () => {
const doc = parse(`
<div class="bn-block-group">
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>A</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>B</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>C</span></div>
</div>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
expect(doc.querySelectorAll('ul')).toHaveLength(1);
expect(doc.querySelectorAll('ul > li')).toHaveLength(3);
});
});
// Quotes
it('converts quote blocks to blockquote', () => {
const doc = parse(`
<div data-content-type="quote"><span>Wise words</span></div>
`);
improveHtmlAccessibility(doc, 'Doc');
const bq = doc.querySelector('blockquote');
expect(bq).not.toBeNull();
expect(bq!.textContent).toBe('Wise words');
});
// Callouts
it('converts callout blocks to aside with role="note"', () => {
const doc = parse(`
<div data-content-type="callout"><span>Note</span></div>
`);
improveHtmlAccessibility(doc, 'Doc');
const aside = doc.querySelector('aside');
expect(aside).not.toBeNull();
expect(aside!.getAttribute('role')).toBe('note');
});
// Checklists
it('wraps check list items in a ul with role="list" and adds aria-checked', () => {
const doc = parse(`
<div>
<div data-content-type="checkListItem">
<input type="checkbox" />
<span>Todo</span>
</div>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
const ul = doc.querySelector('ul.checklist');
expect(ul).not.toBeNull();
expect(ul!.getAttribute('role')).toBe('list');
expect(doc.querySelector('input')!.getAttribute('aria-checked')).toBe(
'false',
);
});
// Code blocks
it('converts code blocks to pre > code and preserves data attributes', () => {
const doc = parse(`
<div data-content-type="codeBlock" class="hl-theme" data-language="js"><span>const x = 1;</span></div>
`);
improveHtmlAccessibility(doc, 'Doc');
const pre = doc.querySelector('pre');
expect(pre).not.toBeNull();
expect(pre!.className).toBe('hl-theme');
expect(pre!.getAttribute('data-language')).toBe('js');
expect(pre!.querySelector('code')!.textContent.trim()).toBe('const x = 1;');
});
// Images
it('adds empty alt to images without one', () => {
const doc = parse(`<img src="photo.png" />`);
improveHtmlAccessibility(doc, 'Doc');
expect(doc.querySelector('img')!.getAttribute('alt')).toBe('');
});
it('does not overwrite an existing alt', () => {
const doc = parse(`<img src="photo.png" alt="A photo" />`);
improveHtmlAccessibility(doc, 'Doc');
expect(doc.querySelector('img')!.getAttribute('alt')).toBe('A photo');
});
// Article wrapper
it('wraps body in article with role="document"', () => {
const doc = parse(`<p>Hello</p>`);
improveHtmlAccessibility(doc, 'Doc');
const article = doc.querySelector('article');
expect(article).not.toBeNull();
expect(article!.getAttribute('role')).toBe('document');
expect(article!.getAttribute('aria-labelledby')).toBe('doc-title');
});
});
@@ -145,20 +145,8 @@ export const improveHtmlAccessibility = (
headingBlocks.forEach((block) => {
const rawLevel = Number(block.getAttribute('data-level')) || 1;
const level = Math.min(Math.max(rawLevel, 1), 6);
const tag = `h${level}`;
const existingHeading = block.querySelector('h1, h2, h3, h4, h5, h6');
const heading = parsedDocument.createElement(tag);
if (existingHeading) {
if (existingHeading.className) {
heading.className = existingHeading.className;
}
moveChildNodes(existingHeading, heading);
} else {
moveChildNodes(block, heading);
}
const heading = parsedDocument.createElement(`h${level}`);
moveChildNodes(block, heading);
block.replaceWith(heading);
});
@@ -190,11 +178,10 @@ export const improveHtmlAccessibility = (
listItem: HTMLElement;
contentType: string;
level: number;
blockOuterIndex: number;
}
const listItemsInfo: ListItemInfo[] = [];
allBlockOuters.forEach((blockOuter, index) => {
allBlockOuters.forEach((blockOuter) => {
const listItem = blockOuter.querySelector<HTMLElement>(listItemSelector);
if (listItem) {
const contentType = listItem.getAttribute('data-content-type');
@@ -205,7 +192,6 @@ export const improveHtmlAccessibility = (
listItem,
contentType,
level,
blockOuterIndex: index,
});
}
}
@@ -220,19 +206,13 @@ export const improveHtmlAccessibility = (
const isBullet = contentType === 'bulletListItem';
const listTag = isBullet ? 'ul' : 'ol';
// Check if previous item continues the same list (same type, level, and
// no non-list block between them in the DOM : e.g. a heading separates lists).
// Check if previous item continues the same list (same type and level)
const previousInfo = idx > 0 ? listItemsInfo[idx - 1] : null;
const isAdjacentBlock = previousInfo && info.blockOuterIndex === previousInfo.blockOuterIndex + 1;
const continuesPreviousList =
isAdjacentBlock &&
previousInfo &&
previousInfo.contentType === contentType &&
previousInfo.level === level;
if (previousInfo && !isAdjacentBlock) {
listStack.length = 0;
}
// Find or create the appropriate list
let targetList: HTMLElement | null = null;
@@ -1,4 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterAll, beforeEach, describe, expect, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
@@ -38,11 +40,17 @@ describe('DocToolBox - Licence', () => {
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = await screen.findByLabelText('Export the document');
await userEvent.click(optionsButton);
// Wait for the export modal to be visible, then assert on its content text.
await screen.findByTestId('modal-export-title');
expect(
await screen.findByLabelText('Export the document'),
screen.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeInTheDocument();
}, 15000);
}, 10000);
test('The export button is not rendered when MIT version is activated', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
@@ -60,5 +68,5 @@ describe('DocToolBox - Licence', () => {
expect(
screen.queryByLabelText('Export the document'),
).not.toBeInTheDocument();
}, 15000);
});
});
@@ -1,8 +1,9 @@
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -38,6 +39,7 @@ import {
useDocUtils,
useDuplicateDoc,
} from '@/docs/doc-management';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { useFocusStore, useResponsiveStore } from '@/stores';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
@@ -86,6 +88,7 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const treeContext = useTreeContext<Doc>();
const queryClient = useQueryClient();
const router = useRouter();
const { isChild, isTopRoot } = useDocUtils(doc);
@@ -111,6 +114,16 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC, KEY_LIST_FAVORITE_DOC],
});
useEffect(() => {
if (selectHistoryModal.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [selectHistoryModal.isOpen, queryClient]);
// Emoji Management
const { emoji } = getEmojiAndTitle(doc.title ?? '');
const { updateDocEmoji } = useDocTitleUpdate();
@@ -4,7 +4,7 @@ import {
} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
import { useEditorStore } from '../../doc-editor';
export const useCopyCurrentEditorToClipboard = () => {
const { editor } = useEditorStore();
@@ -21,8 +21,8 @@ export const useCopyCurrentEditorToClipboard = () => {
try {
const editorContentFormatted =
asFormat === 'html'
? editor.blocksToHTMLLossy()
: editor.blocksToMarkdownLossy();
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
const successMessage =
asFormat === 'markdown'
@@ -44,7 +44,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
void queryClient.resetQueries({
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
@@ -14,11 +14,6 @@ vi.mock('@/stores', () => ({
}),
}));
vi.mock('@gouvfr-lasuite/ui-kit', async () => ({
...(await vi.importActual('@gouvfr-lasuite/ui-kit')),
useTreeContext: () => null,
}));
describe('useDocTitleUpdate', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -4,12 +4,9 @@ import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { BlockNoteReader } from '@/docs/doc-editor/components/BlockNoteEditor';
import { DocEditorContainer } from '@/docs/doc-editor/components/DocEditor';
import { BlockNoteReader, DocEditorContainer } from '@/docs/doc-editor/';
import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management';
import { useDocVersion } from '../api/useDocVersion';
import { Versions } from '../types';
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
import { DocVersionHeader } from './DocVersionHeader';
@@ -1,4 +1,4 @@
import { Doc } from '../doc-management/types';
import { Doc } from '../doc-management';
export interface APIListVersions {
count: number;
@@ -1,4 +1,4 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import i18next from 'i18next';
import { DateTime } from 'luxon';
@@ -73,9 +73,7 @@ describe('DocsGridItemDate', () => {
});
it(`should render rendered the updated_at field in the correct language`, async () => {
await act(async () => {
await i18next.changeLanguage('fr');
});
await i18next.changeLanguage('fr');
render(
<DocsGridItemDate
@@ -92,9 +90,7 @@ describe('DocsGridItemDate', () => {
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
await act(async () => {
await i18next.changeLanguage('en');
});
await i18next.changeLanguage('en');
});
[
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ImperativePanelHandle,
Panel,
@@ -22,12 +23,15 @@ type ResizableLeftPanelProps = {
maxPanelSizePx?: number;
};
const RESIZE_HANDLE_ID = 'left-panel-resize-handle';
export const ResizableLeftPanel = ({
leftPanel,
children,
minPanelSizePx = 300,
maxPanelSizePx = 450,
}: ResizableLeftPanelProps) => {
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const { isPanelOpen } = useLeftPanelStore();
const ref = useRef<ImperativePanelHandle>(null);
@@ -96,6 +100,50 @@ export const ResizableLeftPanel = ({
};
}, [isDesktop]);
/**
* Workaround: NVDA does not enter focus mode for role="separator"
* intercepted by browse-mode navigation and never reach the handle.
* Changing the role to "slider" makes NVDA reliably switch to focus
* mode, restoring progressive keyboard resize with arrow keys.
*/
useEffect(() => {
if (!isPanelOpen) {
return;
}
const handle = document.getElementById(RESIZE_HANDLE_ID);
if (!handle) {
return;
}
handle.setAttribute('role', 'slider');
handle.setAttribute('aria-orientation', 'vertical');
handle.setAttribute('aria-label', t('Resize sidebar'));
const updateValueText = () => {
const value = handle.getAttribute('aria-valuenow');
if (value) {
const widthPx = Math.round(
(parseFloat(value) / 100) * window.innerWidth,
);
handle.setAttribute(
'aria-valuetext',
t('Sidebar width: {{widthPx}} pixels', { widthPx }),
);
}
};
updateValueText();
const observer = new MutationObserver(updateValueText);
observer.observe(handle, {
attributes: true,
attributeFilter: ['aria-valuenow'],
});
return () => {
observer.disconnect();
};
}, [isPanelOpen, t]);
const handleResize = (sizePercent: number) => {
const widthPx = (sizePercent / 100) * window.innerWidth;
savedWidthPxRef.current = widthPx;
@@ -103,7 +151,7 @@ export const ResizableLeftPanel = ({
};
return (
<PanelGroup direction="horizontal">
<PanelGroup direction="horizontal" keyboardResizeBy={1}>
<Panel
ref={ref}
className="--docs--resizable-left-panel"
@@ -132,6 +180,7 @@ export const ResizableLeftPanel = ({
</Panel>
{isPanelOpen && (
<PanelResizeHandle
id={RESIZE_HANDLE_ID}
style={{
borderRightWidth: '1px',
borderRightStyle: 'solid',
@@ -1,7 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/../package.json', () => ({
default: { version: '0.0.0' },
}));
describe('DocsDB', () => {
beforeEach(() => {
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
@@ -15,16 +20,17 @@ describe('DocsDB', () => {
{ version: '3.0.0', expected: 3000000 },
{ version: '10.20.30', expected: 10020030 },
].forEach(({ version, expected }) => {
it(`correctly computes version for ${version}`, async () => {
it(`correctly computes version for ${version}`, () => {
vi.doMock('@/../package.json', () => ({
default: { version },
}));
const module = await import('../DocsDB');
const result = (module as any).getCurrentVersion();
expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected);
previousExpected = result;
return vi.importActual('../DocsDB').then((module: any) => {
const result = module.getCurrentVersion();
expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected);
previousExpected = result;
});
});
});
});
+8 -4
View File
@@ -1,9 +1,16 @@
/// <reference types="vitest" />
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
plugins: [
react(),
tsconfigPaths({
root: '.',
projects: ['./tsconfig.json'],
}),
],
test: {
globals: true,
environment: 'jsdom',
@@ -15,7 +22,4 @@ export default defineConfig({
define: {
'process.env.NODE_ENV': 'test',
},
resolve: {
tsconfigPaths: true,
},
});
+4 -4
View File
@@ -32,17 +32,17 @@
},
"resolutions": {
"@tiptap/extensions": "3.19.0",
"@types/node": "24.12.0",
"@types/node": "24.10.13",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "10.0.3",
"eslint": "10.0.1",
"glob": "13.0.6",
"prosemirror-view": "1.41.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"typescript": "5.9.3",
"wrap-ansi": "10.0.0",
"yjs": "13.6.30"
"wrap-ansi": "9.0.2",
"yjs": "13.6.29"
},
"packageManager": "yarn@1.22.22"
}
@@ -2,7 +2,7 @@ const js = require('@eslint/js');
const nextPlugin = require('@next/eslint-plugin-next');
const tanstackQuery = require('@tanstack/eslint-plugin-query');
const { defineConfig } = require('eslint/config');
const importPlugin = require('eslint-plugin-import-x');
const importPlugin = require('eslint-plugin-import');
const jsxA11y = require('eslint-plugin-jsx-a11y');
const prettier = require('eslint-plugin-prettier');
const react = require('eslint-plugin-react');
@@ -18,22 +18,22 @@
},
"dependencies": {
"@eslint/js": "10.0.1",
"@next/eslint-plugin-next": "16.1.7",
"@next/eslint-plugin-next": "16.1.6",
"@tanstack/eslint-plugin-query": "5.91.4",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@typescript-eslint/utils": "8.57.1",
"@vitest/eslint-plugin": "1.6.12",
"eslint-config-next": "16.1.7",
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"@vitest/eslint-plugin": "1.6.9",
"eslint-config-next": "16.1.6",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jest": "29.15.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.10.0",
"eslint-plugin-playwright": "2.5.1",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-testing-library": "7.16.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-testing-library": "7.15.4",
"prettier": "3.8.1"
},
"packageManager": "yarn@1.22.22"
+2 -2
View File
@@ -19,8 +19,8 @@
"@types/node": "*",
"eslint-plugin-docs": "*",
"eslint-plugin-import": "2.32.0",
"i18next-parser": "9.4.0",
"jest": "30.3.0",
"i18next-parser": "9.3.0",
"jest": "30.2.0",
"ts-jest": "29.4.6",
"typescript": "*",
"yargs": "18.0.0"
+6 -6
View File
@@ -18,10 +18,10 @@
"dependencies": {
"@blocknote/server-util": "0.47.1",
"@hocuspocus/server": "3.4.4",
"@sentry/node": "10.43.0",
"@sentry/profiling-node": "10.43.0",
"@sentry/node": "10.38.0",
"@sentry/profiling-node": "10.38.0",
"@tiptap/extensions": "*",
"axios": "1.13.6",
"axios": "1.13.5",
"cors": "2.8.6",
"express": "5.2.1",
"express-ws": "5.0.2",
@@ -36,16 +36,16 @@
"@types/express": "5.0.6",
"@types/express-ws": "3.0.6",
"@types/node": "*",
"@types/supertest": "7.2.0",
"@types/supertest": "6.0.3",
"@types/ws": "8.18.1",
"cross-env": "10.1.0",
"eslint-plugin-docs": "*",
"nodemon": "3.1.14",
"nodemon": "3.1.11",
"supertest": "7.2.2",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"typescript": "*",
"vitest": "4.1.0",
"vitest": "4.0.18",
"vitest-mock-extended": "3.1.0",
"ws": "8.19.0"
},
+2037 -2350
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -7,9 +7,6 @@
"@html-to/text-cli": "0.5.4",
"mjml": "4.18.0"
},
"resolutions": {
"minimatch": "^9.0.7"
},
"private": true,
"scripts": {
"build-mjml-to-html": "bash ./bin/mjml-to-html",
+13 -6
View File
@@ -110,7 +110,7 @@ boolbase@^1.0.0:
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
brace-expansion@^2.0.2:
brace-expansion@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
@@ -562,12 +562,19 @@ mime@^2.4.6:
resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
minimatch@9.0.1, minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.7:
version "9.0.9"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
minimatch@9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
dependencies:
brace-expansion "^2.0.2"
brace-expansion "^2.0.1"
minimatch@^9.0.3, minimatch@^9.0.4:
version "9.0.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
dependencies:
brace-expansion "^2.0.1"
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
version "7.1.2"