mirror of
https://github.com/strapi/strapi.git
synced 2025-12-28 23:57:32 +00:00
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:
parent
cc76d35eed
commit
68e91e14ec
@ -46,6 +46,7 @@ const Wrapper = ({ children, baseEditor = defaultBaseEditor }: WrapperProps) =>
|
||||
disabled={false}
|
||||
name="blocks"
|
||||
setLiveText={() => {}}
|
||||
isExpandedMode={false}
|
||||
>
|
||||
{children}
|
||||
</BlocksEditorProvider>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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 };
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -106,6 +106,7 @@ const Wrapper = ({
|
||||
disabled={false}
|
||||
name="blocks"
|
||||
setLiveText={jest.fn()}
|
||||
isExpandedMode={false}
|
||||
>
|
||||
{children}
|
||||
</BlocksEditorProvider>
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user