Skip to content

Commit d591e31

Browse files
authored
Fix layout and component block floating toolbars being shown behind other elements (#7604)
1 parent 51f3a22 commit d591e31

File tree

12 files changed

+643
-539
lines changed

12 files changed

+643
-539
lines changed

.changeset/good-tigers-begin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@keystone-6/fields-document': patch
3+
---
4+
5+
Fixed layout and component block floating toolbars being shown behind other elements
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/** @jsxRuntime classic */
2+
/** @jsx jsx */
3+
import { jsx, useTheme } from '@keystone-ui/core';
4+
import { Trash2Icon } from '@keystone-ui/icons/icons/Trash2Icon';
5+
import { Tooltip } from '@keystone-ui/tooltip';
6+
import { ReactNode, useMemo, useState, useCallback, Fragment } from 'react';
7+
import { RenderElementProps } from 'slate-react';
8+
import { Stack } from '@keystone-ui/core';
9+
import { Button as KeystoneUIButton } from '@keystone-ui/button';
10+
import { ToolbarGroup, ToolbarButton, ToolbarSeparator } from '../primitives';
11+
import {
12+
PreviewPropsForToolbar,
13+
ObjectField,
14+
ComponentSchema,
15+
ComponentBlock,
16+
NotEditable,
17+
} from './api';
18+
import { clientSideValidateProp } from './utils';
19+
import { GenericPreviewProps } from './api';
20+
import {
21+
FormValueContentFromPreviewProps,
22+
NonChildFieldComponentSchema,
23+
} from './form-from-preview';
24+
25+
export function ChromefulComponentBlockElement(props: {
26+
children: ReactNode;
27+
renderedBlock: ReactNode;
28+
componentBlock: ComponentBlock & { chromeless?: false };
29+
previewProps: PreviewPropsForToolbar<ObjectField<Record<string, ComponentSchema>>>;
30+
elementProps: Record<string, unknown>;
31+
onRemove: () => void;
32+
attributes: RenderElementProps['attributes'];
33+
}) {
34+
const { colors, fields, spacing, typography } = useTheme();
35+
36+
const isValid = useMemo(
37+
() =>
38+
clientSideValidateProp(
39+
{ kind: 'object', fields: props.componentBlock.schema },
40+
props.elementProps
41+
),
42+
43+
[props.componentBlock, props.elementProps]
44+
);
45+
46+
const [editMode, setEditMode] = useState(false);
47+
const onCloseEditMode = useCallback(() => {
48+
setEditMode(false);
49+
}, []);
50+
const onShowEditMode = useCallback(() => {
51+
setEditMode(true);
52+
}, []);
53+
54+
const ChromefulToolbar = props.componentBlock.toolbar ?? DefaultToolbarWithChrome;
55+
return (
56+
<div
57+
{...props.attributes}
58+
css={{
59+
marginBottom: spacing.xlarge,
60+
marginTop: spacing.xlarge,
61+
paddingLeft: spacing.xlarge,
62+
position: 'relative',
63+
':before': {
64+
content: '" "',
65+
backgroundColor: editMode ? colors.linkColor : colors.border,
66+
borderRadius: 4,
67+
width: 4,
68+
position: 'absolute',
69+
left: 0,
70+
top: 0,
71+
bottom: 0,
72+
zIndex: 1,
73+
},
74+
}}
75+
>
76+
<NotEditable
77+
css={{
78+
color: fields.legendColor,
79+
display: 'block',
80+
fontSize: typography.fontSize.small,
81+
fontWeight: typography.fontWeight.bold,
82+
lineHeight: 1,
83+
marginBottom: spacing.small,
84+
textTransform: 'uppercase',
85+
}}
86+
>
87+
{props.componentBlock.label}
88+
</NotEditable>
89+
{editMode ? (
90+
<Fragment>
91+
<FormValue isValid={isValid} props={props.previewProps} onClose={onCloseEditMode} />
92+
<div css={{ display: 'none' }}>{props.children}</div>
93+
</Fragment>
94+
) : (
95+
<Fragment>
96+
{props.renderedBlock}
97+
<ChromefulToolbar
98+
isValid={isValid}
99+
onRemove={props.onRemove}
100+
onShowEditMode={onShowEditMode}
101+
props={props.previewProps}
102+
/>
103+
</Fragment>
104+
)}
105+
</div>
106+
);
107+
}
108+
109+
function DefaultToolbarWithChrome({
110+
onShowEditMode,
111+
onRemove,
112+
isValid,
113+
}: {
114+
onShowEditMode(): void;
115+
onRemove(): void;
116+
props: any;
117+
isValid: boolean;
118+
}) {
119+
const theme = useTheme();
120+
return (
121+
<ToolbarGroup as={NotEditable} marginTop="small">
122+
<ToolbarButton
123+
onClick={() => {
124+
onShowEditMode();
125+
}}
126+
>
127+
Edit
128+
</ToolbarButton>
129+
<ToolbarSeparator />
130+
<Tooltip content="Remove" weight="subtle">
131+
{attrs => (
132+
<ToolbarButton
133+
variant="destructive"
134+
onClick={() => {
135+
onRemove();
136+
}}
137+
{...attrs}
138+
>
139+
<Trash2Icon size="small" />
140+
</ToolbarButton>
141+
)}
142+
</Tooltip>
143+
{!isValid && (
144+
<Fragment>
145+
<ToolbarSeparator />
146+
<span
147+
css={{
148+
color: theme.palette.red500,
149+
display: 'flex',
150+
alignItems: 'center',
151+
paddingLeft: theme.spacing.small,
152+
}}
153+
>
154+
Please edit the form, there are invalid fields.
155+
</span>
156+
</Fragment>
157+
)}
158+
</ToolbarGroup>
159+
);
160+
}
161+
162+
function FormValue({
163+
onClose,
164+
props,
165+
isValid,
166+
}: {
167+
props: GenericPreviewProps<NonChildFieldComponentSchema, unknown>;
168+
onClose(): void;
169+
isValid: boolean;
170+
}) {
171+
const [forceValidation, setForceValidation] = useState(false);
172+
173+
return (
174+
<Stack gap="xlarge" contentEditable={false}>
175+
<FormValueContentFromPreviewProps {...props} forceValidation={forceValidation} />
176+
<KeystoneUIButton
177+
size="small"
178+
tone="active"
179+
weight="bold"
180+
onClick={() => {
181+
if (isValid) {
182+
onClose();
183+
} else {
184+
setForceValidation(true);
185+
}
186+
}}
187+
>
188+
Done
189+
</KeystoneUIButton>
190+
</Stack>
191+
);
192+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/** @jsxRuntime classic */
2+
/** @jsx jsx */
3+
import { jsx, useTheme } from '@keystone-ui/core';
4+
import { Trash2Icon } from '@keystone-ui/icons/icons/Trash2Icon';
5+
import { useControlledPopover } from '@keystone-ui/popover';
6+
import { Tooltip } from '@keystone-ui/tooltip';
7+
import { ReactNode } from 'react';
8+
import { RenderElementProps } from 'slate-react';
9+
import { InlineDialog, ToolbarButton } from '../primitives';
10+
import { ComponentBlock, PreviewPropsForToolbar, ObjectField, ComponentSchema } from './api';
11+
12+
export function ChromelessComponentBlockElement(props: {
13+
renderedBlock: ReactNode;
14+
componentBlock: ComponentBlock & { chromeless: true };
15+
previewProps: PreviewPropsForToolbar<ObjectField<Record<string, ComponentSchema>>>;
16+
isOpen: boolean;
17+
onRemove: () => void;
18+
attributes: RenderElementProps['attributes'];
19+
}) {
20+
const { trigger, dialog } = useControlledPopover(
21+
{ isOpen: props.isOpen, onClose: () => {} },
22+
{ modifiers: [{ name: 'offset', options: { offset: [0, 8] } }] }
23+
);
24+
const { spacing } = useTheme();
25+
const ChromelessToolbar = props.componentBlock.toolbar ?? DefaultToolbarWithoutChrome;
26+
return (
27+
<div
28+
{...props.attributes}
29+
css={{
30+
marginBottom: spacing.xlarge,
31+
marginTop: spacing.xlarge,
32+
}}
33+
>
34+
<div {...trigger.props} ref={trigger.ref}>
35+
{props.renderedBlock}
36+
{props.isOpen && (
37+
<InlineDialog {...dialog.props} ref={dialog.ref}>
38+
<ChromelessToolbar onRemove={props.onRemove} props={props.previewProps} />
39+
</InlineDialog>
40+
)}
41+
</div>
42+
</div>
43+
);
44+
}
45+
46+
function DefaultToolbarWithoutChrome({
47+
onRemove,
48+
}: {
49+
onRemove(): void;
50+
props: Record<string, any>;
51+
}) {
52+
return (
53+
<Tooltip content="Remove" weight="subtle">
54+
{attrs => (
55+
<ToolbarButton
56+
variant="destructive"
57+
onMouseDown={event => {
58+
event.preventDefault();
59+
onRemove();
60+
}}
61+
{...attrs}
62+
>
63+
<Trash2Icon size="small" />
64+
</ToolbarButton>
65+
)}
66+
</Tooltip>
67+
);
68+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/** @jsxRuntime classic */
2+
/** @jsx jsx */
3+
import { jsx } from '@keystone-ui/core';
4+
import React, { useContext } from 'react';
5+
import { useMemo, ReactElement } from 'react';
6+
import { Element } from 'slate';
7+
import { ComponentBlock } from './api';
8+
import { createGetPreviewProps, getKeysForArrayValue } from './preview-props';
9+
import { ReadonlyPropPath } from './utils';
10+
11+
export const ChildrenByPathContext = React.createContext<Record<string, ReactElement>>({});
12+
13+
export function ChildFieldEditable({ path }: { path: readonly string[] }) {
14+
const childrenByPath = useContext(ChildrenByPathContext);
15+
const child = childrenByPath[JSON.stringify(path)];
16+
if (child === undefined) {
17+
return null;
18+
}
19+
return child;
20+
}
21+
22+
export function ComponentBlockRender({
23+
componentBlock,
24+
element,
25+
onChange,
26+
children,
27+
}: {
28+
element: Element & { type: 'component-block' };
29+
onChange: (cb: (props: Record<string, unknown>) => Record<string, unknown>) => void;
30+
componentBlock: ComponentBlock;
31+
children: any;
32+
}) {
33+
const getPreviewProps = useMemo(() => {
34+
return createGetPreviewProps(
35+
{ kind: 'object', fields: componentBlock.schema },
36+
onChange,
37+
path => <ChildFieldEditable path={path} />
38+
);
39+
}, [onChange, componentBlock]);
40+
41+
const previewProps = getPreviewProps(element.props);
42+
43+
const childrenByPath: Record<string, ReactElement> = {};
44+
let maybeChild: ReactElement | undefined;
45+
children.forEach((child: ReactElement) => {
46+
const propPath = child.props.children.props.element.propPath;
47+
if (propPath === undefined) {
48+
maybeChild = child;
49+
} else {
50+
childrenByPath[JSON.stringify(propPathWithIndiciesToKeys(propPath, element.props))] = child;
51+
}
52+
});
53+
54+
const ComponentBlockPreview = componentBlock.preview;
55+
56+
return (
57+
<ChildrenByPathContext.Provider value={childrenByPath}>
58+
{useMemo(
59+
() => (
60+
<ComponentBlockPreview {...previewProps} />
61+
),
62+
[previewProps, ComponentBlockPreview]
63+
)}
64+
<span css={{ display: 'none' }}>{maybeChild}</span>
65+
</ChildrenByPathContext.Provider>
66+
);
67+
}
68+
69+
// note this is written to avoid crashing when the given prop path doesn't exist in the value
70+
// this is because editor updates happen asynchronously but we have some logic to ensure
71+
// that updating the props of a component block synchronously updates it
72+
// (this is primarily to not mess up things like cursors in inputs)
73+
// this means that sometimes the child elements will be inconsistent with the values
74+
// so to deal with this, we return a prop path this is "wrong" but won't break anything
75+
function propPathWithIndiciesToKeys(propPath: ReadonlyPropPath, val: any): readonly string[] {
76+
return propPath.map(key => {
77+
if (typeof key === 'string') {
78+
val = val?.[key];
79+
return key;
80+
}
81+
if (!Array.isArray(val)) {
82+
val = undefined;
83+
return '';
84+
}
85+
const keys = getKeysForArrayValue(val);
86+
val = val?.[key];
87+
return keys[key];
88+
});
89+
}

packages/fields-document/src/DocumentEditor/component-blocks/edit-mode.tsx

Lines changed: 0 additions & 40 deletions
This file was deleted.

0 commit comments

Comments
 (0)