🐛(frontend) fix interlinking modal clipping

Depend the parent block, the modal search may be
clipped by the parent block. We now use the portal
to render the modal search, which will not be
affected by the parent block's clipping.
This commit is contained in:
Anthony LC
2026-04-14 18:19:27 +02:00
parent 203b3edcae
commit b3dd8f2e39
2 changed files with 238 additions and 199 deletions

View File

@@ -17,6 +17,7 @@ and this project adheres to
- 🚸(frontend) redirect on current url tab after 401 #2197
- 🐛(frontend) abort check media status unmount #2194
- ✨(backend) order pinned documents by last updated at #2028
- 🐛(frontend) fix interlinking modal clipping #2213
- 🛂(frontend) fix cannot manage member on small screen #2226
- 🐛(backend) load jwks url when OIDC_RS_PRIVATE_KEY_STR is set

View File

@@ -4,8 +4,9 @@ import {
} from '@blocknote/core';
import { useBlockNoteEditor } from '@blocknote/react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Popover } from '@mantine/core';
import type { KeyboardEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -90,18 +91,27 @@ export const SearchPage = ({
const { untitledDocument } = useTrans();
const isEditable = editor.isEditable;
const treeContext = useTreeContext<Doc>();
const modalRef = useRef<HTMLDivElement>(null);
const dropdownId = useId();
const [popoverOpened, setPopoverOpened] = useState(false);
/**
* createReactInlineContentSpec add automatically the focus after
* the inline content, so we need to set the focus on the input
* after the component is mounted.
* We also defer opening the popover to after mount so that
* floating-ui attaches scroll/resize listeners correctly.
*/
useEffect(() => {
setTimeout(() => {
const timeoutId = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
setPopoverOpened(true);
}, 100);
}, [inputRef]);
return () => clearTimeout(timeoutId);
}, []);
const closeSearch = (insertContent: string) => {
if (!isEditable) {
@@ -131,9 +141,7 @@ export const SearchPage = ({
closeSearch('');
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
// Allow arrow keys to be handled by the command menu for navigation
const commandList = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector('[cmdk-list]');
const commandList = modalRef.current?.querySelector('[cmdk-list]');
// Create a synthetic keyboard event for the command menu
const syntheticEvent = new KeyboardEvent('keydown', {
@@ -145,11 +153,9 @@ export const SearchPage = ({
e.preventDefault();
} else if (e.key === 'Enter') {
// Handle Enter key to select the currently highlighted item
const selectedItem = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector(
'[cmdk-item][data-selected="true"]',
) as HTMLElement;
const selectedItem = modalRef.current?.querySelector(
'[cmdk-item][data-selected="true"]',
) as HTMLElement;
selectedItem?.click();
e.preventDefault();
@@ -158,204 +164,236 @@ export const SearchPage = ({
return (
<Box as="span" $position="relative">
<Box
as="span"
className="inline-content"
$background={colorsTokens['gray-100']}
$color="var(--c--globals--colors--gray-700)"
$direction="row"
$radius="3px"
$padding="1px"
$display="inline-flex"
tabIndex={-1} // Ensure the span is focusable
<Popover
position="bottom"
opened={popoverOpened}
withinPortal={true}
hideDetached={false}
>
{' '}
{trigger}
<Box
as="input"
name="doc-search-input"
$padding={{ left: '3px' }}
$css={inputStyle}
ref={inputRef}
$display="inline-flex"
onInput={(e) => {
const value = (e.target as HTMLInputElement).value;
setSearch(value);
}}
onKeyDown={handleKeyDown}
autoComplete="off"
/>
</Box>
<Box
$minWidth={isDesktop ? '330px' : '220px'}
$width="fit-content"
$position="absolute"
$css={css`
top: 28px;
z-index: 1000;
& .quick-search-container [cmdk-root] {
border-radius: inherit;
}
`}
>
<QuickSearch showInput={false}>
<Card
<Popover.Target>
<Box
as="span"
className="inline-content"
$background={colorsTokens['gray-100']}
$color="var(--c--globals--colors--gray-700)"
$direction="row"
$radius="3px"
$padding="1px"
$display="inline-flex"
tabIndex={-1} // Ensure the span is focusable
>
{' '}
<Box as="span" aria-hidden="true">
{trigger}
</Box>
<Box
as="input"
name="doc-search-input"
role="combobox"
aria-label={t('Search for a document')}
aria-expanded={popoverOpened}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls={dropdownId}
$padding={{ left: '3px' }}
$css={inputStyle}
ref={inputRef}
$display="inline-flex"
onInput={(e) => {
const value = (e.target as HTMLInputElement).value;
setSearch(value);
}}
onKeyDown={handleKeyDown}
autoComplete="off"
/>
</Box>
</Popover.Target>
<Popover.Dropdown>
<Box
ref={modalRef}
id={dropdownId}
role="listbox"
aria-label={t('Search results')}
$minWidth={isDesktop ? '330px' : '220px'}
$width="fit-content"
$zIndex="10"
$css={css`
box-shadow: 0 0 3px 0px var(--c--globals--colors--gray-200);
& > div {
margin-top: var(--c--globals--spacings--0);
& [cmdk-group-heading] {
padding: 0.4rem;
margin: 0;
}
position: relative;
& [cmdk-group-items] .ml-b {
margin-left: 0rem;
padding: 0.5rem;
font-size: 14px;
display: block;
}
.mantine-Popover-dropdown[data-position='bottom'] & {
top: -10px;
}
.mantine-Popover-dropdown[data-position='top'] & {
top: 10px;
}
& [cmdk-item] {
border-radius: 0;
}
& .--docs--doc-search-item > div {
gap: 0.8rem;
}
& .quick-search-container [cmdk-root] {
border-radius: inherit;
}
`}
$margin={{ top: '0.5rem' }}
>
<DocSearchContent
groupName={t('Select a document')}
search={search}
target={DocSearchTarget.CURRENT}
parentPath={treeContext?.root?.path}
onSelect={(doc) => {
if (!isEditable) {
return;
}
updateInlineContent({
type: 'interlinkingSearchInline',
props: {
disabled: true,
trigger,
},
});
contentRef(null);
editor.insertInlineContent([
{
type: 'interlinkingLinkInline',
props: {
docId: doc.id,
title: doc.title || untitledDocument,
},
},
]);
editor.focus();
}}
renderSearchElement={(doc) => {
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
doc.title || untitledDocument,
);
return (
<QuickSearchItemContent
left={
<Box
$direction="row"
$gap="0.2rem"
$align="center"
$padding={{ vertical: '0.5rem', horizontal: '0.2rem' }}
$width="100%"
>
<Box
$css={css`
width: 24px;
flex-shrink: 0;
`}
>
{emoji ? (
<Text $size="18px">{emoji}</Text>
) : (
<FoundPageIcon
width="100%"
style={{ maxHeight: '24px' }}
/>
)}
</Box>
<Text
$size="sm"
$color="var(--c--globals--colors--gray-1000)"
spellCheck="false"
>
{titleWithoutEmoji}
</Text>
</Box>
<QuickSearch showInput={false}>
<Card
$css={css`
box-shadow: 0 0 3px 0px var(--c--globals--colors--gray-200);
& > div {
margin-top: var(--c--globals--spacings--0);
& [cmdk-group-heading] {
padding: 0.4rem;
margin: 0;
}
right={
<Icon iconName="keyboard_return" spellCheck="false" />
& [cmdk-group-items] .ml-b {
margin-left: 0rem;
padding: 0.5rem;
font-size: 14px;
display: block;
}
/>
);
}}
/>
<QuickSearchGroup
group={{
groupName: '',
elements: [],
endActions: [
{
onSelect: createChildDoc,
content: (
<Box
$css={css`
border-top: 1px solid
var(--c--globals--colors--gray-200);
`}
$width="100%"
>
<Box
$direction="row"
$gap="0.4rem"
$align="center"
$padding={{
vertical: '0.5rem',
horizontal: '0.3rem',
}}
$css={css`
&:hover {
background-color: var(
--c--globals--colors--gray-100
);
}
`}
>
<AddPageIcon />
<Text
$size="sm"
$color="var(--c--globals--colors--gray-1000)"
contentEditable={false}
& [cmdk-item] {
border-radius: 0;
}
& .--docs--doc-search-item > div {
gap: 0.8rem;
}
}
`}
$margin={{ top: '0.5rem' }}
>
<DocSearchContent
groupName={t('Select a document')}
search={search}
target={DocSearchTarget.CURRENT}
parentPath={treeContext?.root?.path}
onSelect={(doc) => {
if (!isEditable) {
return;
}
updateInlineContent({
type: 'interlinkingSearchInline',
props: {
disabled: true,
trigger,
},
});
contentRef(null);
editor.insertInlineContent([
{
type: 'interlinkingLinkInline',
props: {
docId: doc.id,
title: doc.title || untitledDocument,
},
},
]);
editor.focus();
}}
renderSearchElement={(doc) => {
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
doc.title || untitledDocument,
);
return (
<QuickSearchItemContent
left={
<Box
$direction="row"
$gap="0.2rem"
$align="center"
$padding={{
vertical: '0.5rem',
horizontal: '0.2rem',
}}
$width="100%"
>
{t('New sub-doc')}
</Text>
</Box>
</Box>
),
},
],
}}
/>
</Card>
</QuickSearch>
</Box>
<Box
$css={css`
width: 24px;
flex-shrink: 0;
`}
>
{emoji ? (
<Text $size="18px">{emoji}</Text>
) : (
<FoundPageIcon
width="100%"
style={{ maxHeight: '24px' }}
/>
)}
</Box>
<Text
$size="sm"
$color="var(--c--globals--colors--gray-1000)"
spellCheck="false"
>
{titleWithoutEmoji}
</Text>
</Box>
}
right={
<Icon iconName="keyboard_return" spellCheck="false" />
}
/>
);
}}
/>
<QuickSearchGroup
group={{
groupName: '',
elements: [],
endActions: [
{
onSelect: createChildDoc,
content: (
<Box
$css={css`
border-top: 1px solid
var(--c--globals--colors--gray-200);
`}
$width="100%"
>
<Box
$direction="row"
$gap="0.4rem"
$align="center"
$padding={{
vertical: '0.5rem',
horizontal: '0.3rem',
}}
$css={css`
&:hover {
background-color: var(
--c--globals--colors--gray-100
);
}
`}
>
<AddPageIcon />
<Text
$size="sm"
$color="var(--c--globals--colors--gray-1000)"
contentEditable={false}
>
{t('New sub-doc')}
</Text>
</Box>
</Box>
),
},
],
}}
/>
</Card>
</QuickSearch>
</Box>
</Popover.Dropdown>
</Popover>
</Box>
);
};