Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to
### Changed

- 📝(docs) improve README and add documentation hub #1870
- ♿️(frontend) restore focus to triggers after closing menus and modals #1863

## [v4.6.0] - 2026-03-03

Expand Down
9 changes: 7 additions & 2 deletions src/frontend/apps/impress/src/components/DropButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Button, Popover } from 'react-aria-components';
import styled, { css } from 'styled-components';

import { useCunninghamTheme } from '@/cunningham';
import { useFocusStore } from '@/stores';

import { BoxProps } from './Box';

Expand Down Expand Up @@ -70,8 +71,9 @@ export const DropButton = ({
const { themeTokens } = useCunninghamTheme();
const font = themeTokens['font']?.['families']['base'];
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
const addLastFocus = useFocusStore((state) => state.addLastFocus);

const triggerRef = useRef(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);

useEffect(() => {
setIsLocalOpen(isOpen);
Expand All @@ -86,7 +88,10 @@ export const DropButton = ({
<>
<StyledButton
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
onPress={() => {
addLastFocus(triggerRef.current);
onOpenChangeHandler(true);
}}
aria-label={label}
data-testid={testId}
$css={css`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { css } from 'styled-components';

import { Box, Icon } from '@/components';
import { Doc } from '@/docs/doc-management';
import { useFocusStore } from '@/stores';

interface BoutonShareProps {
displayNbAccess: boolean;
Expand All @@ -23,6 +24,7 @@ export const BoutonShare = ({
open,
}: BoutonShareProps) => {
const { t } = useTranslation();
const addLastFocus = useFocusStore((state) => state.addLastFocus);
const treeContext = useTreeContext<Doc>();

/**
Expand Down Expand Up @@ -63,7 +65,10 @@ export const BoutonShare = ({
disabled={isDisabled}
/>
}
onClick={open}
onClick={(e) => {
addLastFocus(e.currentTarget as HTMLElement);
open();
}}
size="medium"
disabled={isDisabled}
>
Expand All @@ -77,7 +82,10 @@ export const BoutonShare = ({
<Button
color="brand"
variant="tertiary"
onClick={open}
onClick={(e) => {
addLastFocus(e.currentTarget as HTMLElement);
open();
}}
size="medium"
disabled={isDisabled}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { useFocusStore, useResponsiveStore } from '@/stores';

import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';

Expand All @@ -70,6 +70,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const selectHistoryModal = useModal();
const modalShare = useModal();

const { addLastFocus, restoreFocus } = useFocusStore();
const { isSmallMobile, isMobile } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { mutate: duplicateDoc } = useDuplicateDoc({
Expand Down Expand Up @@ -224,7 +225,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
icon={
<Icon iconName="download" $color="inherit" aria-hidden={true} />
}
onClick={() => {
onClick={(e) => {
addLastFocus(e.currentTarget as HTMLElement);
setIsModalExportOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
Expand All @@ -249,17 +251,29 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {

{modalShare.isOpen && (
<DocShareModal
onClose={() => modalShare.close()}
onClose={() => {
modalShare.close();
restoreFocus();
}}
doc={doc}
isRootDoc={treeContext?.root?.id === doc.id}
/>
)}
{isModalExportOpen && ModalExport && (
<ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} />
<ModalExport
onClose={() => {
setIsModalExportOpen(false);
restoreFocus();
}}
doc={doc}
/>
)}
{isModalRemoveOpen && (
<ModalRemoveDoc
onClose={() => setIsModalRemoveOpen(false)}
onClose={() => {
setIsModalRemoveOpen(false);
restoreFocus();
}}
doc={doc}
onSuccess={() => {
const isTopParent = doc.id === treeContext?.root?.id;
Expand All @@ -281,7 +295,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
)}
{selectHistoryModal.isOpen && (
<ModalSelectVersion
onClose={() => selectHistoryModal.close()}
onClose={() => {
selectHistoryModal.close();
restoreFocus();
}}
doc={doc}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
TextType,
emojidata,
} from '@/components';
import { useFocusStore } from '@/stores';

import { useDocTitleUpdate } from '../hooks/useDocTitleUpdate';

Expand Down Expand Up @@ -41,6 +42,7 @@ export const DocIcon = ({
}: DocIconProps) => {
const { updateDocEmoji } = useDocTitleUpdate();
const { t } = useTranslation();
const { addLastFocus, restoreFocus } = useFocusStore();

const iconRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -90,12 +92,16 @@ export const DocIcon = ({
});
}

if (!openEmojiPicker) {
addLastFocus(iconRef.current);
}
setOpenEmojiPicker(!openEmojiPicker);
}
};

const handleEmojiSelect = ({ native }: { native: string }) => {
setOpenEmojiPicker(false);
restoreFocus();

// Update document emoji if docId is provided
if (docId && title !== undefined) {
Expand All @@ -108,6 +114,7 @@ export const DocIcon = ({

const handleClickOutside = () => {
setOpenEmojiPicker(false);
restoreFocus();
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,16 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const buttonOptionRef = useRef<HTMLDivElement | null>(null);

const handleKeyDown = (e: React.KeyboardEvent) => {
// F2: focus first action button
const shouldOpenActions = !menuOpen && node.isFocused;
const target = e.target as HTMLElement | null;
const isInActions = !!target?.closest('.light-doc-item-actions');
const isOnEmojiButton = !!target?.closest('.--docs--doc-icon');

const shouldOpenActions =
!menuOpen && !isInActions && (node.isFocused || isOnEmojiButton);
if (e.key === 'F2' && shouldOpenActions) {
buttonOptionRef.current?.focus();
e.stopPropagation();
e.preventDefault();
return;
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,24 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
// Handle keyboard navigation for root item
const handleRootKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// F2: focus first action button
if (e.key === 'F2' && !rootActionsOpen) {
e.preventDefault();
rootButtonOptionRef.current?.focus();
const target = e.target as HTMLElement | null;
const isInActions = !!target?.closest('.doc-tree-root-item-actions');
const isOnEmojiButton = !!target?.closest('.--docs--doc-icon');
const isOnRootItem = target === e.currentTarget;

if (e.key === 'F2' && !rootActionsOpen && !isInActions) {
if (
isOnEmojiButton ||
isOnRootItem ||
target?.classList.contains('c__tree-view--node')
) {
e.preventDefault();
rootButtonOptionRef.current?.focus();
}
return;
}

// Ignore if focus is in actions
const target = e.target as HTMLElement | null;
if (target?.closest('.doc-tree-root-item-actions')) {
if (isInActions) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
useTrans,
} from '@/docs/doc-management';
import { DocShareModal } from '@/docs/doc-share';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { useFocusStore } from '@/stores';

import { DocMoveModal } from './DocMoveModal';

Expand All @@ -29,13 +31,21 @@ interface DocsGridActionsProps {

export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
const { t } = useTranslation();
const restoreFocus = useFocusStore((state) => state.restoreFocus);

const deleteModal = useModal();
const shareModal = useModal();
const importModal = useModal();
const { untitledDocument } = useTrans();

const { mutate: duplicateDoc } = useDuplicateDoc();
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: () => {
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
if (mainContent) {
requestAnimationFrame(() => mainContent.focus());
}
},
});

const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_FAVORITE_DOC],
Expand Down Expand Up @@ -135,15 +145,30 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
</DropdownMenu>

{deleteModal.isOpen && (
<ModalRemoveDoc onClose={deleteModal.onClose} doc={doc} />
<ModalRemoveDoc
onClose={() => {
deleteModal.onClose();
restoreFocus();
}}
doc={doc}
/>
)}
{shareModal.isOpen && (
<DocShareModal doc={doc} onClose={shareModal.close} />
<DocShareModal
doc={doc}
onClose={() => {
shareModal.close();
restoreFocus();
}}
/>
)}
{importModal.isOpen && (
<DocMoveModal
doc={doc}
onClose={importModal.close}
onClose={() => {
importModal.close();
restoreFocus();
}}
isOpen={importModal.isOpen}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { Doc } from '@/docs/doc-management';
import { DocShareModal } from '@/docs/doc-share';
import { useFocusStore } from '@/stores';

type Props = {
doc: Doc;
Expand All @@ -14,6 +15,7 @@ export const DocsGridItemSharedButton = ({ doc, disabled }: Props) => {
const sharedCount = doc.nb_accesses_direct;
const isShared = sharedCount - 1 > 0;
const shareModal = useModal();
const { addLastFocus, restoreFocus } = useFocusStore();

if (!isShared) {
return <Box $minWidth="50px">&nbsp;</Box>;
Expand All @@ -40,6 +42,7 @@ export const DocsGridItemSharedButton = ({ doc, disabled }: Props) => {
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
addLastFocus(event.currentTarget as HTMLElement);
shareModal.open();
}}
color="brand"
Expand All @@ -60,7 +63,13 @@ export const DocsGridItemSharedButton = ({ doc, disabled }: Props) => {
</Button>
</Tooltip>
{shareModal.isOpen && (
<DocShareModal doc={doc} onClose={shareModal.close} />
<DocShareModal
doc={doc}
onClose={() => {
shareModal.close();
restoreFocus();
}}
/>
)}
</>
);
Expand Down
1 change: 1 addition & 0 deletions src/frontend/apps/impress/src/stores/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './useBroadcastStore';
export * from './useFocusStore';
export * from './useResponsiveStore';
23 changes: 23 additions & 0 deletions src/frontend/apps/impress/src/stores/useFocusStore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { create } from 'zustand';

interface UseFocusStore {
lastFocusedElement: HTMLElement | null;
addLastFocus: (target: HTMLElement | null) => void;
restoreFocus: () => void;
}

export const useFocusStore = create<UseFocusStore>((set, get) => ({
lastFocusedElement: null,
addLastFocus: (target) => set({ lastFocusedElement: target }),
restoreFocus: () => {
const { lastFocusedElement } = get();
if (!lastFocusedElement) {
return;
}

requestAnimationFrame(() => {
lastFocusedElement.focus();
});
set({ lastFocusedElement: null });
},
}));
Loading