Skip to content
12 changes: 6 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ and this project adheres to
- ♿ remove redundant aria-label on hidden icons and update tests #1432
- ♿ improve semantic structure and aria roles of leftpanel #1431
- ♿ add default background to left panel for better accessibility #1423
- ♿ improve NVDA navigation in DocShareModal #1396
- ✨(frontend) doc emojis improvements #1381
- add an EmojiPicker in the document tree and document title
- remove emoji buttons in menus
- 🩹(frontend) on main pages do not display leading emoji as page icon #1381
- 🩹(frontend) handle properly emojis in interlinking #1381

### Fixed

Expand All @@ -30,12 +36,6 @@ and this project adheres to
- 🐛(frontend) fix legacy role computation #1376
- 🐛(frontend) scroll back to top when navigate to a document #1406

### Changed

- ♿(frontend) improve accessibility:
- ♿improve NVDA navigation in DocShareModal #1396


## [3.7.0] - 2025-09-12

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ interface EmojiPickerProps {
emojiData: EmojiMartData;
onClickOutside: () => void;
onEmojiSelect: ({ native }: { native: string }) => void;
withOverlay?: boolean;
}

export const EmojiPicker = ({
emojiData,
onClickOutside,
onEmojiSelect,
withOverlay = false,
}: EmojiPickerProps) => {
const { i18n } = useTranslation();

return (
const pickerContent = (
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
<Picker
data={emojiData}
Expand All @@ -30,4 +32,27 @@ export const EmojiPicker = ({
/>
</Box>
);

if (withOverlay) {
return (
<>
{/* Transparent overlay to close by clicking outside */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
zIndex: 999,
backgroundColor: 'transparent',
}}
onClick={onClickOutside}
/>
{pickerContent}
</>
);
}

return pickerContent;
};
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const CalloutBlock = createReactBlockSpec(
emojiData={emojidata}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
withOverlay={true}
/>
)}
<Box as="p" className="inline-content" ref={contentRef} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { createReactInlineContentSpec } from '@blocknote/react';
import { useEffect } from 'react';
import { css } from 'styled-components';

import { StyledLink, Text } from '@/components';
import { Icon, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
import { useDoc } from '@/docs/doc-management';
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management';

export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
{
Expand Down Expand Up @@ -52,6 +52,8 @@ interface LinkSelectedProps {
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
const { colorsTokens } = useCunninghamTheme();

const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);

return (
<StyledLink
href={url}
Expand All @@ -71,9 +73,21 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
transition: background-color 0.2s ease-in-out;
`}
>
<SelectedPageIcon width={11.5} />
<Text $weight="500" spellCheck="false" $size="16px" $display="inline">
{title}
{emoji ? (
<Icon iconName={emoji} $size="16px" />
) : (
<SelectedPageIcon width={11.5} />
)}
<Text
$weight="500"
spellCheck="false"
$size="16px"
$display="inline"
$css={css`
margin-left: 0.3rem;
`}
>
{titleWithoutEmoji}
</Text>
</StyledLink>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Tooltip } from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -8,13 +7,13 @@ import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
getEmojiAndTitle,
useDocStore,
useTrans,
useUpdateDoc,
} from '@/docs/doc-management';
import { useBroadcastStore, useResponsiveStore } from '@/stores';
import { DocIcon, useDocTitleUpdate } from '@/features/docs/doc-management';
import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg';
import { useResponsiveStore } from '@/stores';

interface DocTitleProps {
doc: Doc;
Expand Down Expand Up @@ -49,48 +48,26 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [titleDisplay, setTitleDisplay] = useState(doc.title);
const treeContext = useTreeContext<Doc>();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title ?? '');
const { spacingsTokens } = useCunninghamTheme();

const { untitledDocument } = useTrans();
const [titleDisplay, setTitleDisplay] = useState(titleWithoutEmoji);

const { broadcast } = useBroadcastStore();

const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess(updatedDoc) {
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${updatedDoc.id}`);

if (!treeContext) {
return;
}

if (treeContext.root?.id === updatedDoc.id) {
treeContext?.setRoot(updatedDoc);
} else {
treeContext?.treeData.updateNode(updatedDoc.id, updatedDoc);
}
},
});
const { updateDocTitle } = useDocTitleUpdate();

const handleTitleSubmit = useCallback(
(inputText: string) => {
let sanitizedTitle = inputText.trim();
sanitizedTitle = sanitizedTitle.replace(/(\r\n|\n|\r)/gm, '');

// When blank we set to untitled
if (!sanitizedTitle) {
setTitleDisplay('');
}

// If mutation we update
if (sanitizedTitle !== doc.title) {
setTitleDisplay(sanitizedTitle);
updateDoc({ id: doc.id, title: sanitizedTitle });
}
const sanitizedTitle = updateDocTitle(
doc,
emoji ? `${emoji} ${inputText}` : inputText,
);
const { titleWithoutEmoji: sanitizedTitleWithoutEmoji } =
getEmojiAndTitle(sanitizedTitle);

setTitleDisplay(sanitizedTitleWithoutEmoji);
},
[doc.id, doc.title, updateDoc],
[doc, updateDocTitle, emoji],
);

const handleKeyDown = (e: React.KeyboardEvent) => {
Expand All @@ -101,43 +78,82 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
};

useEffect(() => {
setTitleDisplay(doc.title);
}, [doc]);
setTitleDisplay(titleWithoutEmoji);
}, [doc, titleWithoutEmoji]);

return (
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
<Box
as="span"
role="textbox"
className="--docs--doc-title-input"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label={`${t('Document title')}`}
aria-multiline={false}
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
$color={colorsTokens['greyscale-1000']}
$minHeight="40px"
$padding={{ right: 'big' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
<Box
$direction="row"
$align="flex-end"
$gap={spacingsTokens['xs']}
$minHeight="40px"
>
<Tooltip content={t('Document emoji')} aria-hidden={true} placement="top">
<Box
$css={css`
height: 36px;
padding: 4px;
padding-top: 3px;
cursor: pointer;
&:hover {
background-color: ${colorsTokens['greyscale-100']};
border-radius: 4px;
}
transition: background-color 0.2s ease-in-out;
`}
>
<DocIcon
withEmojiPicker={doc.abilities.partial_update}
docId={doc.id}
title={doc.title}
emoji={emoji}
$size="25px"
defaultIcon={
<SimpleFileIcon
width="25px"
height="25px"
aria-hidden="true"
aria-label={t('Simple document icon')}
color={colorsTokens['primary-500']}
/>
}
/>
</Box>
</Tooltip>

<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
<Box
as="span"
role="textbox"
className="--docs--doc-title-input"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label={`${t('Document title')}`}
aria-multiline={false}
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
$color={colorsTokens['greyscale-1000']}
$padding={{ right: 'big' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
</Box>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
KEY_DOC,
KEY_LIST_DOC,
ModalRemoveDoc,
getEmojiAndTitle,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
Expand All @@ -33,6 +34,7 @@ import {
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';

import { useDocTitleUpdate } from '../../doc-management/hooks/useDocTitleUpdate';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';

const ModalExport = Export?.ModalExport;
Expand Down Expand Up @@ -92,6 +94,13 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
});
}, [selectHistoryModal.isOpen, queryClient]);

// Emoji Management
const { emoji } = getEmojiAndTitle(doc.title ?? '');
const { updateDocEmoji } = useDocTitleUpdate();
const removeEmoji = () => {
updateDocEmoji(doc.id, doc.title ?? '', '');
};

const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
Expand Down Expand Up @@ -127,6 +136,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
...(emoji && doc.abilities.partial_update
? [
{
label: t('Remove emoji'),
icon: 'emoji_emotions',
callback: removeEmoji,
},
]
: []),
{
label: t('Version history'),
icon: 'history',
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading