feat(blocks): Ability to expand blocks input (#19125)

* tests: added

* fix: styling changes, variable renaming, test case updated

* fix: removed focus as its not needed

* tests: added default context value

* tests: added missing context prop

* fix: build process errors from merger

---------

Co-authored-by: Josh <37798644+joshuaellis@users.noreply.github.com>
Co-authored-by: Rémi de Juvigny <8087692+remidej@users.noreply.github.com>
This commit is contained in:
Madhuri Sandbhor 2024-01-03 19:51:27 +05:30 committed by GitHub
parent cc76d35eed
commit 68e91e14ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 170 additions and 11 deletions

View File

@ -46,6 +46,7 @@ const Wrapper = ({ children, baseEditor = defaultBaseEditor }: WrapperProps) =>
disabled={false}
name="blocks"
setLiveText={() => {}}
isExpandedMode={false}
>
{children}
</BlocksEditorProvider>

View File

@ -18,13 +18,16 @@ import { type ModifiersStore } from './Modifiers';
import { getAttributesToClear } from './utils/conversions';
import { getEntries, isLinkNode, isListNode } from './utils/types';
const StyledEditable = styled(Editable)`
const StyledEditable = styled(Editable)<{ isExpandedMode: boolean }>`
// The outline style is set on the wrapper with :focus-within
outline: none;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spaces[3]};
height: 100%;
// For fullscreen align input in the center with fixed width
width: ${(props) => (props.isExpandedMode ? '512px' : '100%')};
margin: auto;
> *:last-child {
padding-bottom: ${({ theme }) => theme.spaces[3]};
@ -392,7 +395,7 @@ interface BlocksInputProps {
}
const BlocksContent = ({ placeholder }: BlocksInputProps) => {
const { editor, disabled, blocks, modifiers, setLiveText } =
const { editor, disabled, blocks, modifiers, setLiveText, isExpandedMode } =
useBlocksEditorContext('BlocksContent');
const blocksRef = React.useRef<HTMLDivElement>(null);
const { formatMessage } = useIntl();
@ -611,6 +614,7 @@ const BlocksContent = ({ placeholder }: BlocksInputProps) => {
<StyledEditable
readOnly={disabled}
placeholder={placeholder}
isExpandedMode={isExpandedMode}
renderElement={renderElement}
renderLeaf={renderLeaf}
onKeyDown={handleKeyDown}

View File

@ -1,7 +1,9 @@
import * as React from 'react';
import { createContext } from '@radix-ui/react-context';
import { InputWrapper, Divider, VisuallyHidden } from '@strapi/design-system';
import { IconButton, Divider, VisuallyHidden } from '@strapi/design-system';
import { pxToRem } from '@strapi/helper-plugin';
import { Expand } from '@strapi/icons';
import { type Attribute } from '@strapi/types';
import { MessageDescriptor, useIntl } from 'react-intl';
import { Editor, type Descendant, createEditor } from 'slate';
@ -20,6 +22,7 @@ import { paragraphBlocks } from './Blocks/Paragraph';
import { quoteBlocks } from './Blocks/Quote';
import { BlocksContent } from './BlocksContent';
import { BlocksToolbar } from './BlocksToolbar';
import { EditorLayout } from './EditorLayout';
import { type ModifiersStore, modifiers } from './Modifiers';
import { withImages } from './plugins/withImages';
import { withLinks } from './plugins/withLinks';
@ -84,6 +87,7 @@ interface BlocksEditorContextValue {
disabled: boolean;
name: string;
setLiveText: (text: string) => void;
isExpandedMode: boolean;
}
const [BlocksEditorProvider, usePartialBlocksEditorContext] =
@ -109,6 +113,12 @@ const EditorDivider = styled(Divider)`
background: ${({ theme }) => theme.colors.neutral200};
`;
const ExpandIconButton = styled(IconButton)`
position: absolute;
bottom: ${pxToRem(12)};
right: ${pxToRem(12)};
`;
/**
* Forces an update of the Slate editor when the value prop changes from outside of Slate.
* The root cause is that Slate is not a controlled component: https://github.com/ianstormtaylor/slate/issues/4612
@ -169,11 +179,16 @@ const BlocksEditor = React.forwardRef<{ focus: () => void }, BlocksEditorProps>(
);
const [liveText, setLiveText] = React.useState('');
const ariaDescriptionId = React.useId();
const [isExpandedMode, setIsExpandedMode] = React.useState(false);
const formattedPlaceholder =
placeholder &&
formatMessage({ id: placeholder.id, defaultMessage: placeholder.defaultMessage });
const handleToggleExpand = () => {
setIsExpandedMode((prev) => !prev);
};
/**
* Editable is not able to hold the ref, https://github.com/ianstormtaylor/slate/issues/4082
* so with "useImperativeHandle" we can use ReactEditor methods to expose to the parent above
@ -236,20 +251,29 @@ const BlocksEditor = React.forwardRef<{ focus: () => void }, BlocksEditorProps>(
disabled={disabled}
name={name}
setLiveText={setLiveText}
isExpandedMode={isExpandedMode}
>
<InputWrapper
direction="column"
alignItems="flex-start"
height="512px"
<EditorLayout
error={error}
disabled={disabled}
hasError={Boolean(error)}
style={{ overflow: 'hidden' }}
aria-describedby={ariaDescriptionId}
onCollapse={handleToggleExpand}
ariaDescriptionId={ariaDescriptionId}
>
<BlocksToolbar />
<EditorDivider width="100%" />
<BlocksContent placeholder={formattedPlaceholder} />
</InputWrapper>
{!isExpandedMode && (
<ExpandIconButton
aria-label={formatMessage({
id: getTranslation('components.Blocks.expand'),
defaultMessage: 'Expand',
})}
onClick={handleToggleExpand}
>
<Expand />
</ExpandIconButton>
)}
</EditorLayout>
</BlocksEditorProvider>
</Slate>
</>

View File

@ -0,0 +1,104 @@
import * as React from 'react';
import { Box, Flex, FocusTrap, Portal, IconButton, InputWrapper } from '@strapi/design-system';
import { useLockScroll, pxToRem } from '@strapi/helper-plugin';
import { Collapse } from '@strapi/icons';
import { useIntl } from 'react-intl';
import styled from 'styled-components';
import { getTranslation } from '../../utils/translations';
import { useBlocksEditorContext } from './BlocksEditor';
const CollapseIconButton = styled(IconButton)`
position: absolute;
bottom: ${pxToRem(12)};
right: ${pxToRem(12)};
`;
const ExpandWrapper = styled(Flex)`
// Background with 20% opacity
background: ${({ theme }) => `${theme.colors.neutral800}1F`};
`;
interface EditorLayoutProps {
children: React.ReactNode;
error?: string;
onCollapse: () => void;
disabled: boolean;
ariaDescriptionId: string;
}
const EditorLayout = ({
children,
error,
disabled,
onCollapse,
ariaDescriptionId,
}: EditorLayoutProps) => {
const { formatMessage } = useIntl();
const { isExpandedMode } = useBlocksEditorContext('editorLayout');
useLockScroll({ lockScroll: isExpandedMode });
if (isExpandedMode) {
return (
<Portal role="dialog" aria-modal={false}>
<FocusTrap onEscape={onCollapse}>
<ExpandWrapper
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
zIndex={4}
justifyContent="center"
onClick={onCollapse}
>
<Box
background="neutral0"
hasRadius
shadow="popupShadow"
overflow="hidden"
width="90%"
height="90%"
onClick={(e) => e.stopPropagation()}
aria-describedby={ariaDescriptionId}
position="relative"
>
<Flex height="100%" alignItems="flex-start" direction="column">
{children}
<CollapseIconButton
aria-label={formatMessage({
id: getTranslation('components.Blocks.collapse'),
defaultMessage: 'Collapse',
})}
onClick={onCollapse}
>
<Collapse />
</CollapseIconButton>
</Flex>
</Box>
</ExpandWrapper>
</FocusTrap>
</Portal>
);
}
return (
<InputWrapper
direction="column"
alignItems="flex-start"
height="512px"
disabled={disabled}
hasError={Boolean(error)}
style={{ overflow: 'hidden' }}
aria-describedby={ariaDescriptionId}
position="relative"
>
{children}
</InputWrapper>
);
};
export { EditorLayout };

View File

@ -3,6 +3,7 @@ import * as React from 'react';
import { lightTheme, ThemeProvider } from '@strapi/design-system';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { IntlProvider } from 'react-intl';
@ -11,6 +12,8 @@ import { BlocksInput } from '../BlocksInput';
import { blocksData } from './mock-schema';
const user = userEvent.setup();
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
useLibrary: () => ({ components: { 'media-library': jest.fn() } }),
@ -90,4 +93,24 @@ describe('BlocksInput', () => {
expect(screen.getByRole('img')).toBeInTheDocument();
});
it('should open editor expand portal when clicking on expand button', async () => {
const { queryByText } = setup({ value: blocksData });
expect(queryByText('Collapse')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Expand/ }));
expect(screen.getByRole('button', { name: /Collapse/ })).toBeInTheDocument();
});
it('should close editor expand portal when clicking on collapse button', async () => {
const { queryByText } = setup({ value: blocksData });
await user.click(screen.getByRole('button', { name: /Expand/ }));
const collapseButton = screen.getByRole('button', { name: /Collapse/ });
expect(collapseButton).toBeInTheDocument();
await user.click(collapseButton);
expect(queryByText('Collapse')).not.toBeInTheDocument();
});
});

View File

@ -106,6 +106,7 @@ const Wrapper = ({
disabled={false}
name="blocks"
setLiveText={jest.fn()}
isExpandedMode={false}
>
{children}
</BlocksEditorProvider>

View File

@ -631,6 +631,8 @@
"components.Blocks.modifiers.strikethrough": "Strikethrough",
"components.Blocks.modifiers.code": "Inline code",
"components.Blocks.link": "Link",
"components.Blocks.expand": "Expand",
"components.Blocks.collapse": "Collapse",
"components.Blocks.popover.text": "Text",
"components.Blocks.popover.text.placeholder": "Enter link text",
"components.Blocks.popover.link": "Link",