Compare commits

...

17 Commits

Author SHA1 Message Date
Cyril ae9f348663 fixup! (frontend) make components accessible to screen readers 2025-09-04 12:21:29 +02:00
Cyril 360b83b957 fixup! (frontend) make components accessible to screen readers 2025-09-04 12:21:28 +02:00
Cyril a88fa5a26c fixup! (frontend) add keyboard navigation for subdocs with focus activation 2025-09-04 12:21:20 +02:00
Cyril e6e024a42c fixup! ️(frontend) improve accessibility of selected document's sub-menu 2025-09-04 11:45:47 +02:00
Cyril 9cdd0bd7f9 fixup! (frontend) add keyboard navigation for subdocs with focus activation 2025-09-04 11:45:44 +02:00
Cyril 66869c2758 fixup! (frontend) add keyboard navigation for subdocs with focus activation 2025-09-04 11:44:38 +02:00
Cyril 057539e2b7 fixup! (frontend) make components accessible to screen readers 2025-09-04 11:44:38 +02:00
Cyril 48ebb5a85c fixup! (frontend) make components accessible to screen readers 2025-09-04 11:44:37 +02:00
Cyril ef9411d5e9 fixup! (frontend) make components accessible to screen readers 2025-09-04 11:44:37 +02:00
Cyril 4456e8b1d6 fixup! ️(frontend) improve accessibility of selected document's sub-menu 2025-09-04 11:44:33 +02:00
Cyril 2f4cd67af0 fixup! (frontend) add keyboard navigation for subdocs with focus activation 2025-09-04 11:42:50 +02:00
Cyril 922d05d7e9 fixup! (frontend) make components accessible to screen readers 2025-09-04 11:42:49 +02:00
Cyril cf835100b0 fixup! (frontend) make components accessible to screen readers 2025-09-04 11:42:48 +02:00
Cyril 74b7afaee2 fixup! (frontend) make components accessible to screen readers 2025-09-04 11:42:48 +02:00
Cyril 5b0e2e37e4 (frontend) make components accessible to screen readers
adds proper aria props and translation keys for accessibility support

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-04 11:42:45 +02:00
Cyril a5e22fddf9 (frontend) add keyboard navigation for subdocs with focus activation
enter/space now trigger only on real focus add useTreeItemKeyboardActivate hook

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-04 11:36:17 +02:00
Cyril d84a9cb24a ️(frontend) improve accessibility of selected document's sub-menu
adds focus style to make the sub-menu accessible unify dropdownmenu

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-04 11:31:43 +02:00
22 changed files with 992 additions and 181 deletions
+2 -1
View File
@@ -25,8 +25,9 @@ and this project adheres to
- #1244
- #1270
- #1282
- #1261
- ♻️(backend) fallback to email identifier when no name #1298
- 🐛(backend) allow ASCII characters in user sub field #1295
- 🐛(backend) allow ASCII characters in user sub field #1295
- ⚡️(frontend) improve fallback width calculation #1333
### Fixed
@@ -314,7 +314,7 @@ test.describe('Doc Editor', () => {
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'AI' }).click();
await page.locator('[data-test="ai-actions"]').click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
@@ -400,11 +400,11 @@ test.describe('Doc Editor', () => {
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
if (!ai_transform && !ai_translate) {
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
await expect(page.locator('[data-test="ai-actions"]')).toBeHidden();
return;
}
await page.getByRole('button', { name: 'AI' }).click();
await page.locator('[data-test="ai-actions"]').click();
if (ai_transform) {
await expect(
@@ -175,10 +175,10 @@ test.describe('Document search', () => {
// Expect to find the first doc
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
page.getByRole('presentation').getByText(firstDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
page.getByRole('presentation').getByText(secondDocTitle),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -196,13 +196,13 @@ test.describe('Document search', () => {
// Now there is a sub page - expect to have the focus on the current doc
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
page.getByRole('presentation').getByText(secondDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondChildDocTitle),
page.getByRole('presentation').getByText(secondChildDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
page.getByRole('presentation').getByText(firstDocTitle),
).toBeHidden();
});
});
@@ -315,3 +315,81 @@ test.describe('Doc Tree: Inheritance', () => {
await expect(docTree.getByText(docParent)).toBeVisible();
});
});
test.describe('Doc tree keyboard interactions (subdocs)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('navigates in the tree and actions with keyboard and toggles menu (options and create childDoc)', async ({
page,
browserName,
}) => {
const [rootDocTitle] = await createDoc(
page,
'doc-tree-keyboard',
browserName,
1,
);
await verifyDocName(page, rootDocTitle);
const { name: childTitle } = await createRootSubPage(
page,
browserName,
'subdoc-tree-actions',
);
await verifyDocName(page, childTitle);
const docTree = page.getByTestId('doc-tree');
const actionsGroup = page.getByRole('toolbar', {
name: `Actions for ${childTitle}`,
});
await expect(actionsGroup).toBeVisible();
const moreOptions = actionsGroup.getByRole('button', {
name: `More options for ${childTitle}`,
});
await expect(moreOptions).toBeVisible();
await moreOptions.focus();
await expect(moreOptions).toBeFocused();
await page.keyboard.press('ArrowRight');
const addChild = actionsGroup.getByTestId('add-child-doc');
await expect(addChild).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(moreOptions).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByText('Copy link')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByText('Copy link')).toBeHidden();
await page.keyboard.press('ArrowRight');
await expect(addChild).toBeFocused();
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await page.keyboard.press('Enter');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const newChildDoc = (await response.json()) as { id: string };
const childButton = page.getByTestId(`doc-sub-page-item-${newChildDoc.id}`);
const childTreeItem = docTree
.locator('.c__tree-view--row')
.filter({ has: childButton })
.first();
await childTreeItem.focus();
});
});
@@ -63,5 +63,6 @@ export const clickOnAddRootSubPage = async (page: Page) => {
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
await rootItem.hover();
await rootItem.getByRole('button', { name: 'add_box' }).click();
await rootItem.getByTestId('add-child-doc').click();
};
@@ -2,6 +2,7 @@ import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
import {
Fragment,
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useRef,
@@ -11,11 +12,10 @@ import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
import { useDropdownKeyboardNav } from '@/hook/useDropdownKeyboardNav';
export type DropdownMenuOption = {
icon?: string;
icon?: string | ReactNode;
label: string;
testId?: string;
value?: string;
@@ -81,14 +81,28 @@ export const DropdownMenu = ({
// Focus selected menu item when menu opens
useEffect(() => {
if (isOpen && menuItemRefs.current.length > 0) {
const selectedIndex = options.findIndex((option) => option.isSelected);
if (selectedIndex !== -1) {
setFocusedIndex(selectedIndex);
setTimeout(() => {
menuItemRefs.current[selectedIndex]?.focus();
}, 0);
}
if (!isOpen || menuItemRefs.current.length === 0) {
return;
}
const selectedIndex = options.findIndex((option) => option.isSelected);
if (selectedIndex !== -1) {
setFocusedIndex(selectedIndex);
setTimeout(() => {
menuItemRefs.current[selectedIndex]?.focus();
}, 0);
return;
}
// Fallback: focus first enabled/visible option
const firstEnabledIndex = options.findIndex(
(opt) => opt.show !== false && !opt.disabled,
);
if (firstEnabledIndex !== -1) {
setFocusedIndex(firstEnabledIndex);
setTimeout(() => {
menuItemRefs.current[firstEnabledIndex]?.focus();
}, 0);
}
}, [isOpen, options]);
@@ -156,7 +170,6 @@ export const DropdownMenu = ({
return;
}
const isDisabled = option.disabled !== undefined && option.disabled;
const isFocused = index === focusedIndex;
return (
<Fragment key={option.label}>
@@ -207,17 +220,8 @@ export const DropdownMenu = ({
}
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: -2px;
background-color: var(--c--theme--colors--greyscale-050);
}
${isFocused &&
css`
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: -2px;
background-color: var(--c--theme--colors--greyscale-050);
`}
`}
>
<Box
@@ -225,14 +229,17 @@ export const DropdownMenu = ({
$align="center"
$gap={spacingsTokens['base']}
>
{option.icon && (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
)}
{option.icon &&
(typeof option.icon === 'string' ? (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
) : (
option.icon
))}
<Text $variation={isDisabled ? '400' : '1000'}>
{option.label}
</Text>
@@ -1,12 +1,9 @@
import { css } from 'styled-components';
import { Box } from '../Box';
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
import { Icon } from '../Icon';
import { Text } from '../Text';
import {
DropdownMenu,
DropdownMenuOption,
} from '../dropdown-menu/DropdownMenu';
export type FilterDropdownProps = {
options: DropdownMenuOption[];
@@ -3,7 +3,7 @@ export * from './Box';
export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './dropdown-menu/DropdownMenu';
export * from './DropdownMenu';
export * from './quick-search';
export * from './Icon';
export * from './InfiniteScroll';
@@ -1,4 +1,5 @@
import { DateTime } from 'luxon';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -26,17 +27,28 @@ type SimpleDocItemProps = {
doc: Doc;
isPinned?: boolean;
showAccesses?: boolean;
onActivate?: () => void;
};
export const SimpleDocItem = ({
doc,
isPinned = false,
showAccesses = false,
onActivate,
}: SimpleDocItemProps) => {
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { untitledDocument } = useTrans();
const router = useRouter();
const handleActivate = () => {
if (onActivate) {
onActivate();
} else {
router.push(`/docs/${doc.id}`);
}
};
const { emoji, titleWithoutEmoji: displayTitle } = getEmojiAndTitle(
doc.title || untitledDocument,
@@ -49,6 +61,9 @@ export const SimpleDocItem = ({
$overflow="auto"
$width="100%"
className="--docs--simple-doc-item"
role="presentation"
onClick={handleActivate}
aria-label={`${t('Open document')} ${doc.title || untitledDocument}`}
>
<Box
$direction="row"
@@ -59,6 +74,7 @@ export const SimpleDocItem = ({
`}
$padding={`${spacingsTokens['3xs']} 0`}
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
aria-hidden="true"
>
{isPinned ? (
<PinnedDocumentIcon
@@ -97,6 +113,7 @@ export const SimpleDocItem = ({
$align="center"
$gap={spacingsTokens['3xs']}
$margin={{ top: '-2px' }}
aria-hidden="true"
>
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
@@ -0,0 +1,76 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BoxButton, Icon } from '@/components';
type ButtonAddChildDocProps = {
onCreateChild: (params: { parentId: string }) => void;
parentId: string;
title?: string | null;
};
export const ButtonAddChildDoc = ({
onCreateChild,
parentId,
title,
}: ButtonAddChildDocProps) => {
const { t } = useTranslation();
const preventDefaultAndStopPropagation = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
e.preventDefault();
},
[],
);
const isValidKeyEvent = useCallback((e: React.KeyboardEvent) => {
return e.key === 'Enter' || e.key === ' ';
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
preventDefaultAndStopPropagation(e);
void onCreateChild({ parentId });
},
[onCreateChild, parentId, preventDefaultAndStopPropagation],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isValidKeyEvent(e)) {
preventDefaultAndStopPropagation(e);
void onCreateChild({ parentId });
}
},
[
onCreateChild,
parentId,
preventDefaultAndStopPropagation,
isValidKeyEvent,
],
);
return (
<BoxButton
as="button"
tabIndex={-1}
data-testid="add-child-doc"
onClick={handleClick}
onKeyDown={handleKeyDown}
color="primary"
aria-label={t('Add child document to {{title}}', {
title: title || t('Untitled document'),
})}
$hasTransition={false}
>
<Icon
variant="filled"
$variation="800"
$theme="primary"
iconName="add_box"
aria-hidden="true"
/>
</BoxButton>
);
};
@@ -0,0 +1,73 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Icon } from '@/components';
type ButtonMoreOptionsProps = {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
title?: string | null;
className?: string;
};
export const ButtonMoreOptions = ({
isOpen,
onOpenChange,
title,
className = 'icon-button',
}: ButtonMoreOptionsProps) => {
const { t } = useTranslation();
const preventDefaultAndStopPropagation = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
e.preventDefault();
},
[],
);
const isValidKeyEvent = useCallback((e: React.KeyboardEvent) => {
return e.key === 'Enter' || e.key === ' ';
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
preventDefaultAndStopPropagation(e);
onOpenChange?.(!isOpen);
},
[isOpen, onOpenChange, preventDefaultAndStopPropagation],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isValidKeyEvent(e)) {
preventDefaultAndStopPropagation(e);
onOpenChange?.(!isOpen);
}
},
[isOpen, onOpenChange, preventDefaultAndStopPropagation, isValidKeyEvent],
);
return (
<Icon
onClick={handleClick}
iconName="more_horiz"
variant="filled"
$theme="primary"
$variation="600"
className={className}
tabIndex={-1}
role="button"
aria-label={t('More options for {{title}}', {
title: title || t('Untitled document'),
})}
aria-haspopup="true"
aria-expanded={isOpen}
onKeyDown={handleKeyDown}
$css={css`
cursor: pointer;
`}
/>
);
};
@@ -5,9 +5,10 @@ import {
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
@@ -15,9 +16,13 @@ import {
useTrans,
} from '@/features/docs/doc-management';
import { DocIcon } from '@/features/docs/doc-management/components/DocIcon';
import { useActionableMode } from '@/features/docs/doc-tree/hooks/useActionableMode';
import { useLoadChildrenOnOpen } from '@/features/docs/doc-tree/hooks/useLoadChildrenOnOpen';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
import SubPageIcon from './../assets/sub-page-logo.svg';
import { DocTreeItemActions } from './DocTreeItemActions';
@@ -38,7 +43,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { node } = props;
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const [actionsOpen, setActionsOpen] = useState(false);
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
const isActive = node.isFocused || menuOpen || isSelectedNow;
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
@@ -46,6 +55,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title || '');
const displayTitle = titleWithoutEmoji || untitledDocument;
const handleActivate = () => {
treeContext?.treeData.setSelectedNode(doc);
router.push(`/docs/${doc.id}`);
};
const { actionsRef, onKeyDownCapture } = useActionableMode(node, menuOpen);
const afterCreate = (createdDoc: Doc) => {
const actualChildren = node.data.children ?? [];
@@ -76,62 +90,94 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
}
};
useKeyboardActivation(
['Enter', ' '],
isActive && !menuOpen,
handleActivate,
true,
);
useLoadChildrenOnOpen(
node.data.value.id,
node.isOpen,
treeContext?.treeData.handleLoadChildren,
treeContext?.treeData.setChildren,
(doc.children?.length ?? 0) > 0 || doc.childrenCount === 0,
);
// prepare the text for the screen reader
const docTitle = doc.title || untitledDocument;
const hasChildren = (doc.children?.length || 0) > 0;
const isExpanded = node.isOpen;
const isSelected = isSelectedNow;
const ariaLabel = docTitle;
return (
<Box
className="--docs-sub-page-item"
draggable={doc.abilities.move && isDesktop}
$position="relative"
role="treeitem"
aria-label={ariaLabel}
aria-selected={isSelected}
aria-expanded={hasChildren ? isExpanded : undefined}
$css={css`
background-color: ${actionsOpen
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
/* Ensure the outline (handled by TreeView) matches the visual area */
.c__tree-view--node {
padding: ${spacingsTokens['3xs']};
border-radius: 4px;
}
.light-doc-item-actions {
display: ${actionsOpen || !isDesktop ? 'flex' : 'none'};
display: flex;
opacity: ${isActive || !isDesktop ? 1 : 0};
position: absolute;
right: 0;
background: ${isDesktop
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
top: 0;
height: 100%;
z-index: 10;
}
.c__tree-view--node.isSelected {
.light-doc-item-actions {
background: var(--c--theme--colors--greyscale-100);
}
}
&:hover {
.c__tree-view--node:hover,
.c__tree-view--node.isFocused {
background-color: var(--c--theme--colors--greyscale-100);
border-radius: 4px;
.light-doc-item-actions {
display: flex;
background: var(--c--theme--colors--greyscale-100);
opacity: 1;
visibility: visible;
/* background: var(--c--theme--colors--greyscale-100); */
}
}
.row.preview & {
background-color: inherit;
}
/* Ensure actions are visible when hovering the whole item container */
&:hover {
.light-doc-item-actions {
display: flex;
opacity: 1;
visibility: visible;
}
}
`}
>
<TreeViewItem
{...props}
onClick={() => {
treeContext?.treeData.setSelectedNode(props.node.data.value as Doc);
router.push(`/docs/${props.node.data.value.id}`);
}}
>
<Box
data-testid={`doc-sub-page-item-${props.node.data.value.id}`}
<TreeViewItem {...props} onClick={handleActivate}>
<BoxButton
onClick={(e) => {
e.stopPropagation();
handleActivate();
}}
tabIndex={-1}
$width="100%"
$direction="row"
$gap={spacingsTokens['xs']}
role="button"
tabIndex={0}
$align="center"
$minHeight="24px"
data-testid={`doc-sub-page-item-${doc.id}`}
aria-label={`${t('Open document')} ${docTitle}`}
>
<Box $width="16px" $height="16px">
<DocIcon emoji={emoji} defaultIcon={<SubPageIcon />} $size="sm" />
@@ -157,25 +203,30 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
iconName="group"
$size="16px"
$variation="400"
aria-hidden="true"
/>
)}
</Box>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions"
>
<DocTreeItemActions
doc={doc}
isOpen={actionsOpen}
onOpenChange={setActionsOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</Box>
</BoxButton>
</TreeViewItem>
<Box
ref={actionsRef}
onKeyDownCapture={onKeyDownCapture}
$direction="row"
$align="center"
className="light-doc-item-actions"
role="toolbar"
aria-label={`${t('Actions for')} ${docTitle}`}
>
<DocTreeItemActions
doc={doc}
isOpen={menuOpen}
onOpenChange={setMenuOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</Box>
);
};
@@ -5,8 +5,8 @@ import {
useResponsive,
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
@@ -15,6 +15,7 @@ import { Doc, SimpleDocItem } from '@/docs/doc-management';
import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree';
import { useMoveDoc } from '../api/useMove';
import { useRootTreeItem } from '../hooks/useRootTreeItem';
import { findIndexInTree } from '../utils';
import { DocSubPageItem } from './DocSubPageItem';
@@ -26,22 +27,30 @@ type DocTreeProps = {
export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const treeContext = useTreeContext<Doc | null>();
const router = useRouter();
const { isDesktop } = useResponsive();
const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null);
const { t } = useTranslation();
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
undefined,
);
const { mutate: moveDoc } = useMoveDoc();
const {
rootIsSelected,
rootActionsOpen,
setRootActionsOpen,
rootActionsRef,
onRootToolbarKeys,
handleRootFocus,
handleRootKeyDown,
handleRootClick,
handleRootActivate,
handleCreateSuccess,
} = useRootTreeItem();
const { data: tree, isFetching } = useDocTree(
{
docId: currentDoc.id,
},
{ docId: currentDoc.id },
{
enabled: !!!treeContext?.root?.id,
queryKey: [KEY_DOC_TREE, { id: currentDoc.id }],
@@ -56,7 +65,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
});
treeContext?.treeData.handleMove(result);
};
/**
* This function resets the tree states.
*/
@@ -64,7 +72,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
if (!treeContext?.root?.id) {
return;
}
treeContext?.setRoot(null);
setInitialOpenState(undefined);
}, [treeContext]);
@@ -77,7 +84,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
if (!treeContext?.root?.id) {
return;
}
const index = findIndexInTree(treeContext.treeData.nodes, currentDoc.id);
if (index === -1 && currentDoc.id !== treeContext.root?.id) {
resetStateTree();
@@ -92,7 +98,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
return () => {
resetStateTree();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -140,18 +145,20 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}
}, [currentDoc, treeContext]);
/**
* This is the main return of the component.
*/
if (!treeContext || !treeContext.root) {
return null;
}
const rootIsSelected =
treeContext.treeData.selectedNode?.id === treeContext.root.id;
return (
<Box
ref={setTreeRoot}
data-testid="doc-tree"
$height="100%"
role="tree"
aria-label={t('Document tree')}
$css={css`
.c__tree-view--container {
z-index: 1;
@@ -171,6 +178,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
>
<Box
data-testid="doc-tree-root-item"
role="treeitem"
aria-label={`${t('Root document')}: ${treeContext.root?.title || t('Untitled document')}`}
aria-selected={rootIsSelected}
tabIndex={0}
onFocus={handleRootFocus}
onKeyDown={handleRootKeyDown}
$css={css`
padding: ${spacingsTokens['2xs']};
border-radius: 4px;
@@ -191,7 +204,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
opacity: 1;
}
}
&:hover {
&:hover,
&:focus-within {
.doc-tree-root-item-actions {
opacity: 1;
}
@@ -203,31 +217,24 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
width: 100%;
`}
href={`/docs/${treeContext.root.id}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
treeContext.treeData.setSelectedNode(
treeContext.root ?? undefined,
);
router.push(`/docs/${treeContext?.root?.id}`);
}}
onClick={handleRootClick}
aria-label={`${t('Open root document')}: ${treeContext.root?.title || t('Untitled document')}`}
tabIndex={-1} // évite le double tabstop
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={treeContext.root} showAccesses={true} />
<SimpleDocItem
doc={treeContext.root}
showAccesses={true}
onActivate={handleRootActivate}
/>
<DocTreeItemActions
doc={treeContext.root}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: treeContext.root?.id ?? undefined,
};
treeContext?.treeData.addChild(null, newDoc);
}}
onCreateSuccess={handleCreateSuccess}
isOpen={rootActionsOpen}
isRoot={true}
onOpenChange={setRootActionsOpen}
actionsRef={rootActionsRef}
onKeyDownCapture={onRootToolbarKeys}
/>
</Box>
</StyledLink>
@@ -8,7 +8,7 @@ import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import { Box, Icon } from '@/components';
import {
Doc,
ModalRemoveDoc,
@@ -17,6 +17,9 @@ import {
useCreateChildDoc,
useDuplicateDoc,
} from '@/docs/doc-management';
import { ButtonAddChildDoc } from '@/features/docs/doc-tree/components/ButtonAddChildDoc';
import { ButtonMoreOptions } from '@/features/docs/doc-tree/components/ButtonMoreOptions';
import { useDropdownFocusManagement } from '@/features/docs/doc-tree/hooks/useDropdownFocusManagement';
import { useDetachDoc } from '../api/useDetach';
import MoveDocIcon from '../assets/doc-extract-bold.svg';
@@ -28,6 +31,8 @@ type DocTreeItemActionsProps = {
onCreateSuccess?: (newDoc: Doc) => void;
onOpenChange?: (isOpen: boolean) => void;
parentId?: string | null;
actionsRef?: React.RefObject<HTMLDivElement>;
onKeyDownCapture?: (e: React.KeyboardEvent) => void;
};
export const DocTreeItemActions = ({
@@ -37,6 +42,8 @@ export const DocTreeItemActions = ({
onCreateSuccess,
onOpenChange,
parentId,
actionsRef,
onKeyDownCapture,
}: DocTreeItemActionsProps) => {
const router = useRouter();
const { t } = useTranslation();
@@ -143,50 +150,59 @@ export const DocTreeItemActions = ({
}
};
useDropdownFocusManagement({
isOpen: !!isOpen,
docId: doc.id,
actionsRef,
});
return (
<Box className="doc-tree-root-item-actions">
<Box
ref={actionsRef}
tabIndex={-1}
onKeyDownCapture={onKeyDownCapture}
$direction="row"
$align="center"
className="--docs--doc-tree-item-actions"
$gap="4px"
$css={css`
&:focus-within {
opacity: 1;
visibility: visible;
}
button:focus-visible,
[role='button']:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
background-color: var(--c--theme--colors--greyscale-050);
border-radius: 4px;
}
.icon-button:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
background-color: var(--c--theme--colors--greyscale-050);
border-radius: 4px;
}
`}
>
<DropdownMenu
options={options}
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<Icon
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onOpenChange?.(!isOpen);
}}
iconName="more_horiz"
variant="filled"
$theme="primary"
$variation="600"
<ButtonMoreOptions
isOpen={isOpen}
onOpenChange={onOpenChange}
title={doc.title}
/>
</DropdownMenu>
{doc.abilities.children_create && (
<BoxButton
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
createChildDoc({
parentId: doc.id,
});
}}
color="primary"
>
<Icon
variant="filled"
$variation="800"
$theme="primary"
iconName="add_box"
/>
</BoxButton>
<ButtonAddChildDoc
onCreateChild={createChildDoc}
parentId={doc.id}
title={doc.title}
/>
)}
</Box>
{deleteModal.isOpen && (
@@ -0,0 +1,16 @@
// Centralized selector constants for doc-tree hooks
export const SELECTORS = {
MODAL:
'[role="dialog"], .c__modal, [data-modal], .c__modal__overlay, .ReactModal_Content',
MODAL_SCROLLER: '.c__modal__scroller',
ACTIONS_CONTAINER: '.--docs--doc-tree-item-actions',
DOC_SUB_PAGE_ITEM: '.--docs-sub-page-item',
ROLE_MENU: '[role="menu"]',
ROLE_MENUITEM_OR_BUTTON:
'[role="menuitem"], button, [tabindex]:not([tabindex="-1"])',
FOCUSABLE:
'button, [role="button"], a[href], input, [tabindex]:not([tabindex="-1"])',
DATA_TESTID_DOC_SUB_PAGE_ITEM: 'doc-sub-page-item-',
DATA_TESTID_DOC_SUB_PAGE_ITEM_PREFIX: '[data-testid^="doc-sub-page-item-"]',
} as const;
@@ -0,0 +1,104 @@
import { useEffect, useRef } from 'react';
import { SELECTORS } from '../dom-selectors';
export type ActionableNodeLike = {
isFocused?: boolean;
focus?: () => void;
};
/**
* Hook to manage keyboard navigation for actionable items in a tree view.
*
* Disables navigation when dropdown menu is open to prevent conflicts.
*/
export const useActionableMode = (
node: ActionableNodeLike,
isMenuOpen?: boolean,
) => {
const actionsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const modalOpen = document.querySelector(SELECTORS.MODAL);
if (!node?.isFocused || modalOpen) {
return;
}
const toActions = (e: KeyboardEvent) => {
const modalOpen = document.querySelector(SELECTORS.MODAL);
if (modalOpen) {
return;
}
if (e.key === 'F2') {
const isAlreadyInActions = actionsRef.current?.contains(
document.activeElement,
);
if (isAlreadyInActions) {
return;
}
e.preventDefault();
const focusables = actionsRef.current?.querySelectorAll<HTMLElement>(
SELECTORS.FOCUSABLE,
);
const first = focusables?.[0];
first?.focus();
}
};
document.addEventListener('keydown', toActions, true);
return () => {
document.removeEventListener('keydown', toActions, true);
};
}, [node?.isFocused]);
const onKeyDownCapture = (e: React.KeyboardEvent) => {
if (isMenuOpen) {
return;
}
const modal = document.querySelector(SELECTORS.MODAL);
if (modal) {
return;
}
if (e.key === 'Escape') {
e.stopPropagation();
node?.focus?.();
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
const focusables = actionsRef.current?.querySelectorAll<HTMLElement>(
SELECTORS.FOCUSABLE,
);
if (!focusables || focusables.length === 0) {
return;
}
const currentIndex = Array.from(focusables).findIndex(
(el) => el === document.activeElement,
);
let nextIndex: number;
if (e.key === 'ArrowLeft') {
nextIndex = currentIndex > 0 ? currentIndex - 1 : focusables.length - 1;
} else {
nextIndex = currentIndex < focusables.length - 1 ? currentIndex + 1 : 0;
}
focusables[nextIndex]?.focus();
}
};
return { actionsRef, onKeyDownCapture };
};
@@ -0,0 +1,101 @@
import { useEffect } from 'react';
import { SELECTORS } from '../dom-selectors';
interface UseDropdownFocusManagementProps {
isOpen: boolean;
docId: string;
actionsRef?: React.RefObject<HTMLDivElement>;
}
export const useDropdownFocusManagement = ({
isOpen,
docId,
actionsRef,
}: UseDropdownFocusManagementProps) => {
// Focus management for dropdown menu opening
useEffect(() => {
if (!isOpen) {
return;
}
const timer = setTimeout(() => {
// Try to find menu in actions container first
const menuElement = actionsRef?.current
?.closest(SELECTORS.ACTIONS_CONTAINER)
?.querySelector(SELECTORS.ROLE_MENU);
if (menuElement) {
const firstMenuItem = menuElement.querySelector<HTMLElement>(
SELECTORS.ROLE_MENUITEM_OR_BUTTON,
);
if (firstMenuItem) {
firstMenuItem.focus();
return;
}
}
// Fallback: find any menu in document
const allMenus = document.querySelectorAll(SELECTORS.ROLE_MENU);
const lastMenu = allMenus[allMenus.length - 1];
if (lastMenu) {
const firstMenuItem = lastMenu.querySelector<HTMLElement>(
SELECTORS.ROLE_MENUITEM_OR_BUTTON,
);
if (firstMenuItem) {
firstMenuItem.focus();
}
}
}, 100);
return () => clearTimeout(timer);
}, [isOpen, actionsRef]);
// Focus management for returning to sub-document when menu closes
useEffect(() => {
if (isOpen) {
return;
}
const timer = setTimeout(() => {
const modal = document.querySelector(SELECTORS.MODAL);
if (modal) {
return;
}
// Only handle focus return if no modal is open
let subPageItem = actionsRef?.current?.closest(
SELECTORS.DOC_SUB_PAGE_ITEM,
);
// If not found, try to find by data-testid
if (!subPageItem) {
const testIdElement = document.querySelector(
`[data-testid="${SELECTORS.DATA_TESTID_DOC_SUB_PAGE_ITEM}${docId}"]`,
);
subPageItem =
testIdElement?.closest(SELECTORS.DOC_SUB_PAGE_ITEM) ||
testIdElement?.parentElement?.closest(SELECTORS.DOC_SUB_PAGE_ITEM);
}
// Focus the sub-document if found
if (subPageItem) {
const focusableElement = subPageItem.querySelector<HTMLElement>(
SELECTORS.DATA_TESTID_DOC_SUB_PAGE_ITEM_PREFIX,
);
if (focusableElement) {
focusableElement.focus();
} else {
(subPageItem as HTMLElement).focus();
}
return;
}
// Fallback: focus actions container
actionsRef?.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, [isOpen, actionsRef, docId]);
};
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { SELECTORS } from '../dom-selectors';
export const useKeyboardActivation = (
keys: string[],
enabled: boolean,
action: () => void,
capture = false,
) => {
useEffect(() => {
if (!enabled) {
return;
}
const onKeyDown = (e: KeyboardEvent) => {
const modal = document.querySelector(SELECTORS.MODAL_SCROLLER);
if (modal) {
e.stopPropagation();
e.stopImmediatePropagation();
return;
}
if (keys.includes(e.key)) {
e.preventDefault();
action();
}
};
document.addEventListener('keydown', onKeyDown, capture);
return () => document.removeEventListener('keydown', onKeyDown, capture);
}, [keys, enabled, action, capture]);
};
@@ -0,0 +1,55 @@
import { useEffect, useRef } from 'react';
/**
* Lazily loads children for a tree node the first time it is expanded.
* Works for both mouse and keyboard expansions.
*/
export const useLoadChildrenOnOpen = <T>(
nodeId: string,
isOpen: boolean,
handleLoadChildren?: (id: string) => Promise<T[]>,
setChildren?: (id: string, children: T[]) => void,
isAlreadyLoaded?: boolean,
) => {
const hasLoadedRef = useRef(false);
// Reset the local loaded flag when the node id changes
useEffect(() => {
hasLoadedRef.current = false;
}, [nodeId]);
useEffect(() => {
if (!isOpen) {
return;
}
if (isAlreadyLoaded) {
hasLoadedRef.current = true;
return;
}
if (hasLoadedRef.current) {
return;
}
if (!handleLoadChildren || !setChildren) {
return;
}
let isCancelled = false;
// Mark as loading to prevent repeated fetches/renders that can cause flicker
hasLoadedRef.current = true;
void handleLoadChildren(nodeId)
.then((children) => {
if (isCancelled) {
return;
}
setChildren(nodeId, children);
})
.catch(() => {
// allow retry on next open
hasLoadedRef.current = false;
});
return () => {
isCancelled = true;
};
}, [isOpen, nodeId, handleLoadChildren, setChildren, isAlreadyLoaded]);
};
@@ -0,0 +1,105 @@
// useRootTreeItem.ts
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { RefObject, useCallback, useState } from 'react';
import type { Doc } from '@/docs/doc-management';
import { useActionableMode } from '../hooks/useActionableMode';
import type { ActionableNodeLike } from '../hooks/useActionableMode';
export function useRootTreeItem() {
const treeContext = useTreeContext<Doc | null>();
const router = useRouter();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const rootIsSelected =
!!treeContext?.root?.id &&
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
/**
* This is a fake node used to reuse the useActionableMode hook on the root.
*/
const fakeRootNode: ActionableNodeLike = {
isFocused: rootIsSelected,
focus: () => {
const root = treeContext?.root;
if (root) {
treeContext?.treeData.setSelectedNode(root);
}
},
};
const { actionsRef: rootActionsRef, onKeyDownCapture: onRootToolbarKeys } =
useActionableMode(fakeRootNode, rootActionsOpen);
const selectRoot = useCallback(() => {
if (treeContext?.root) {
treeContext.treeData.setSelectedNode(treeContext.root);
}
}, [treeContext]);
const navigateToRoot = useCallback(() => {
const id = treeContext?.root?.id;
if (id) {
router.push(`/docs/${id}`);
}
}, [router, treeContext?.root?.id]);
const handleRootFocus = useCallback(() => {
selectRoot();
}, [selectRoot]);
const handleRootKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectRoot();
navigateToRoot();
}
},
[selectRoot, navigateToRoot],
);
const handleRootClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
selectRoot();
navigateToRoot();
},
[selectRoot, navigateToRoot],
);
const handleRootActivate = useCallback(() => {
selectRoot();
navigateToRoot();
}, [selectRoot, navigateToRoot]);
const handleCreateSuccess = useCallback(
(createdDoc: Doc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: treeContext?.root?.id ?? undefined,
};
treeContext?.treeData.addChild(null, newDoc);
},
[treeContext],
);
return {
rootIsSelected,
rootActionsOpen,
setRootActionsOpen,
rootActionsRef: rootActionsRef as RefObject<HTMLDivElement>,
onRootToolbarKeys,
handleRootFocus,
handleRootKeyDown,
handleRootClick,
handleRootActivate,
handleCreateSuccess,
};
}
@@ -1,6 +1,8 @@
import { RefObject, useEffect } from 'react';
import { DropdownMenuOption } from '../DropdownMenu';
import { DropdownMenuOption } from '@/components/DropdownMenu';
import { useKeyboardActivation } from '../features/docs/doc-tree/hooks/useKeyboardActivation';
type UseDropdownKeyboardNavProps = {
isOpen: boolean;
@@ -19,6 +21,22 @@ export const useDropdownKeyboardNav = ({
setFocusedIndex,
onOpenChange,
}: UseDropdownKeyboardNavProps) => {
useKeyboardActivation(['Enter', ' '], isOpen, () => {
if (focusedIndex === -1) {
return;
}
const enabledIndices = options
.map((opt, i) => (opt.show !== false && !opt.disabled ? i : -1))
.filter((i) => i !== -1);
const selectedOpt = options[enabledIndices[focusedIndex]];
if (selectedOpt?.callback) {
onOpenChange(false);
void selectedOpt.callback();
}
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) {
@@ -26,57 +44,42 @@ export const useDropdownKeyboardNav = ({
}
const enabledIndices = options
.map((option, index) =>
option.show !== false && !option.disabled ? index : -1,
)
.filter((index) => index !== -1);
.map((opt, i) => (opt.show !== false && !opt.disabled ? i : -1))
.filter((i) => i !== -1);
switch (event.key) {
case 'ArrowDown':
case 'ArrowDown': {
event.preventDefault();
const nextIndex =
focusedIndex < enabledIndices.length - 1 ? focusedIndex + 1 : 0;
const nextEnabledIndex = enabledIndices[nextIndex];
const nextEnabled = enabledIndices[nextIndex];
setFocusedIndex(nextIndex);
menuItemRefs.current[nextEnabledIndex]?.focus();
menuItemRefs.current[nextEnabled]?.focus();
break;
}
case 'ArrowUp':
case 'ArrowUp': {
event.preventDefault();
const prevIndex =
focusedIndex > 0 ? focusedIndex - 1 : enabledIndices.length - 1;
const prevEnabledIndex = enabledIndices[prevIndex];
const prevEnabled = enabledIndices[prevIndex];
setFocusedIndex(prevIndex);
menuItemRefs.current[prevEnabledIndex]?.focus();
menuItemRefs.current[prevEnabled]?.focus();
break;
}
case 'Enter':
case ' ':
event.preventDefault();
if (focusedIndex >= 0 && focusedIndex < enabledIndices.length) {
const selectedOptionIndex = enabledIndices[focusedIndex];
const selectedOption = options[selectedOptionIndex];
if (selectedOption && selectedOption.callback) {
onOpenChange(false);
void selectedOption.callback();
}
}
break;
case 'Escape':
case 'Escape': {
event.preventDefault();
onOpenChange(false);
break;
}
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
return () => document.removeEventListener('keydown', handleKeyDown);
}, [
isOpen,
focusedIndex,
@@ -157,7 +157,17 @@
"Document access mode": "Mod aotreet an teul",
"Warning": "Diwallit",
"Why you can't edit the document?": "Perak ne c'hellit ket aozañ ar restr?",
"Write": "Skrivañ"
"Write": "Skrivañ",
"Document tree": "Gwezennadur ar restroù",
"Root document": "Restr gwrizienn",
"Open root document": "Digeriñ ar restr gwrizienn",
"Open document": "Digeriñ ar restr",
"Actions for": "Obererezhioù evit",
"Add child document to": "Ouzhpennañ ur restr bugel da",
"More options for": "Dibarzhioù ouzhpenn evit",
"expanded": "digeret",
"collapsed": "serret",
"selected": "diuzet"
}
},
"de": {
@@ -274,6 +284,8 @@
"Move": "Verschieben",
"Move document": "Dokument verschieben",
"Move to my docs": "In \"Meine Dokumente\" verschieben",
"More options": "Weitere Optionen",
"Add child document": "Unterdokument hinzufügen",
"My docs": "Meine Dokumente",
"Name": "Name",
"New doc": "Neues Dokument",
@@ -363,6 +375,16 @@
"Warning": "Warnung",
"Why you can't edit the document?": "Warum können Sie dieses Dokument nicht bearbeiten?",
"Write": "Schreiben",
"Document tree": "Dokumentenbaum",
"Root document": "Stammdokument",
"Open root document": "Stammdokument öffnen",
"Open document": "Dokument öffnen",
"Actions for": "Aktionen für",
"Add child document to": "Unterdokument hinzufügen zu",
"More options for": "Weitere Optionen für",
"expanded": "erweitert",
"collapsed": "zusammengeklappt",
"selected": "ausgewählt",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Sie sind der einzige Besitzer dieser Gruppe. Machen Sie ein anderes Mitglied zum Gruppenbesitzer, bevor Sie Ihre eigene Rolle ändern oder aus Ihrem Dokument entfernen können.",
"You must be at least the editor of the target document": "Sie müssen dafür mindestens die Rolle \"Mitbearbeiter\" haben",
"You must be the owner to move the document": "Sie müssen Besitzer des Dokuments sein, um es zu verschieben",
@@ -387,7 +409,18 @@
"Shared with {{count}} users_many": "Shared with {{count}} users",
"Shared with {{count}} users_one": "Shared with {{count}} user",
"Shared with {{count}} users_other": "Shared with {{count}} users",
"Updated": "Updated"
"Updated": "Updated",
"Add child document": "Add child document",
"Document tree": "Document tree",
"Root document": "Root document",
"Open root document": "Open root document",
"Open document": "Open document",
"Actions for": "Actions for",
"Add child document to": "Add child document to",
"More options for": "More options for",
"expanded": "expanded",
"collapsed": "collapsed",
"selected": "selected"
}
},
"es": {
@@ -572,9 +605,21 @@
"Document access mode": "Modo de acceso al documento",
"Warning": "Aviso",
"Write": "Escribe",
"Document tree": "Árbol de documentos",
"Root document": "Documento raíz",
"Open root document": "Abrir documento raíz",
"Open document": "Abrir documento",
"Actions for": "Acciones para",
"Add child document to": "Añadir documento hijo a",
"More options for": "Más opciones para",
"expanded": "expandido",
"collapsed": "contraído",
"selected": "seleccionado",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Eres el único propietario de este grupo, haz que otro miembro sea el propietario del grupo para poder cambiar tu propio rol o ser eliminado del documento.",
"Your current document will revert to this version.": "Tu documento actual se revertirá a esta versión.",
"Your {{format}} was downloaded succesfully": "Su {{format}} se ha descargado correctamente",
"More options": "Más opciones",
"Add child document": "Añadir documento hijo",
"home-content-open-source-part1": "Docs está construido sobre <2>Django Rest Framework</2> y <6>Next.js</6>. También utilizamos <9>Yjs</9> y <13>BlockNote.js</13>, dos proyectos que estamos orgullosos de patrocinar.",
"home-content-open-source-part2": "Puede autoalojar fácilmente Docs (consulte nuestra <2>documentación</2> de instalación).<br/>Docs utiliza una <7>licencia</7> (MIT) adecuada para la innovación y las empresas.<br/>Se aceptan contribuciones (consulte nuestra hoja de ruta <13>aquí</13>).",
"home-content-open-source-part3": "Docs es el resultado de un esfuerzo conjunto llevado a cabo por los gobiernos francés 🇫🇷🥖 <1>(DINUM)</1> y alemán 🇩🇪🥨 <5>(ZenDiS)</5>."
@@ -712,6 +757,8 @@
"Move": "Déplacer",
"Move document": "Déplacer le document",
"Move to my docs": "Déplacer vers mes docs",
"More options": "Plus d'options",
"Add child document": "Ajouter un document enfant",
"My docs": "Mes documents",
"Name": "Nom",
"New doc": "Nouveau doc",
@@ -807,6 +854,16 @@
"Warning": "Attention",
"Why you can't edit the document?": "Pourquoi vous ne pouvez pas modifier le document ?",
"Write": "Écrire",
"Document tree": "Arborescence des documents",
"Root document": "Document racine",
"Open root document": "Ouvrir le document racine",
"Open document": "Ouvrir le document",
"Actions for": "Actions pour",
"Add child document to": "Ajouter un document enfant à",
"More options for": "Plus d'options pour",
"expanded": "développé",
"collapsed": "replié",
"selected": "sélectionné",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Vous êtes le seul propriétaire de ce groupe, faites d'un autre membre le propriétaire du groupe, avant de pouvoir modifier votre propre rôle ou vous supprimer du document.",
"You can view this document but need additional access to see its members or modify settings.": "Vous pouvez voir ce document mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres.",
"You cannot restrict access to a subpage relative to its parent page.": "Vous ne pouvez pas restreindre l'accès à une sous-page par rapport à sa page parente.",
@@ -1148,9 +1205,21 @@
"Document access mode": "Document toegangsmodus",
"Warning": "Waarschuwing",
"Write": "Schrijf",
"Document tree": "Documentenboom",
"Root document": "Hoofddocument",
"Open root document": "Hoofddocument openen",
"Open document": "Document openen",
"Actions for": "Acties voor",
"Add child document to": "Onderliggend document toevoegen aan",
"More options for": "Meer opties voor",
"expanded": "uitgeklapt",
"collapsed": "ingeklapt",
"selected": "geselecteerd",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "U bent de enige eigenaar van deze groep, maak een ander lid de groepseigenaar voordat u uw eigen rol kunt wijzigen of kan worden verwijderd van het document.",
"Your current document will revert to this version.": "Uw huidige document wordt teruggezet naar deze versie.",
"Your {{format}} was downloaded succesfully": "Jouw {{format}} is succesvol gedownload",
"More options": "Meer opties",
"Add child document": "Onderliggend document toevoegen",
"home-content-open-source-part1": "Docs is gebouwd op <2>Django Rest Framework</2> en <6>Next.js</6>. We gebruiken ook <9>Yjs</9> en <13>BlockNote.js</13>, twee projecten die we met trots sponsoren.",
"home-content-open-source-part2": "U kunt Docs eenvoudig zelf hosten (zie onze <2>installatiedocumentatie</2>).<br/>Docs gebruikt een <7>licentie</7> (MIT) die is afgestemd op innovatie en ondernemingen.<br/>Bijdragen zijn welkom (zie onze routekaart <13>hier</13>).",
"home-content-open-source-part3": "Docs is het resultaat van een gezamenlijke inspanning geleid door de Franse 🇫🇷🥖 <1>(DINUM)</1> en Duitse 🇩🇪🥨 <5>(ZenDiS)</5> overheden."