Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae9f348663 | |||
| 360b83b957 | |||
| a88fa5a26c | |||
| e6e024a42c | |||
| 9cdd0bd7f9 | |||
| 66869c2758 | |||
| 057539e2b7 | |||
| 48ebb5a85c | |||
| ef9411d5e9 | |||
| 4456e8b1d6 | |||
| 2f4cd67af0 | |||
| 922d05d7e9 | |||
| cf835100b0 | |||
| 74b7afaee2 | |||
| 5b0e2e37e4 | |||
| a5e22fddf9 | |||
| d84a9cb24a |
+2
-1
@@ -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();
|
||||
};
|
||||
|
||||
+36
-29
@@ -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';
|
||||
|
||||
+17
@@ -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;
|
||||
`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+95
-44
@@ -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>
|
||||
|
||||
+45
-29
@@ -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 };
|
||||
};
|
||||
+101
@@ -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,
|
||||
};
|
||||
}
|
||||
+32
-29
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user