Skip to content

Commit

Permalink
Fix layout and component block floating toolbars being shown behind o…
Browse files Browse the repository at this point in the history
…ther elements (#7604)
  • Loading branch information
emmatown committed Jun 9, 2022
1 parent 51f3a22 commit d591e31
Show file tree
Hide file tree
Showing 12 changed files with 643 additions and 539 deletions.
5 changes: 5 additions & 0 deletions .changeset/good-tigers-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-6/fields-document': patch
---

Fixed layout and component block floating toolbars being shown behind other elements
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/** @jsxRuntime classic */
/** @jsx jsx */
import { jsx, useTheme } from '@keystone-ui/core';
import { Trash2Icon } from '@keystone-ui/icons/icons/Trash2Icon';
import { Tooltip } from '@keystone-ui/tooltip';
import { ReactNode, useMemo, useState, useCallback, Fragment } from 'react';
import { RenderElementProps } from 'slate-react';
import { Stack } from '@keystone-ui/core';
import { Button as KeystoneUIButton } from '@keystone-ui/button';
import { ToolbarGroup, ToolbarButton, ToolbarSeparator } from '../primitives';
import {
PreviewPropsForToolbar,
ObjectField,
ComponentSchema,
ComponentBlock,
NotEditable,
} from './api';
import { clientSideValidateProp } from './utils';
import { GenericPreviewProps } from './api';
import {
FormValueContentFromPreviewProps,
NonChildFieldComponentSchema,
} from './form-from-preview';

export function ChromefulComponentBlockElement(props: {
children: ReactNode;
renderedBlock: ReactNode;
componentBlock: ComponentBlock & { chromeless?: false };
previewProps: PreviewPropsForToolbar<ObjectField<Record<string, ComponentSchema>>>;
elementProps: Record<string, unknown>;
onRemove: () => void;
attributes: RenderElementProps['attributes'];
}) {
const { colors, fields, spacing, typography } = useTheme();

const isValid = useMemo(
() =>
clientSideValidateProp(
{ kind: 'object', fields: props.componentBlock.schema },
props.elementProps
),

[props.componentBlock, props.elementProps]
);

const [editMode, setEditMode] = useState(false);
const onCloseEditMode = useCallback(() => {
setEditMode(false);
}, []);
const onShowEditMode = useCallback(() => {
setEditMode(true);
}, []);

const ChromefulToolbar = props.componentBlock.toolbar ?? DefaultToolbarWithChrome;
return (
<div
{...props.attributes}
css={{
marginBottom: spacing.xlarge,
marginTop: spacing.xlarge,
paddingLeft: spacing.xlarge,
position: 'relative',
':before': {
content: '" "',
backgroundColor: editMode ? colors.linkColor : colors.border,
borderRadius: 4,
width: 4,
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
zIndex: 1,
},
}}
>
<NotEditable
css={{
color: fields.legendColor,
display: 'block',
fontSize: typography.fontSize.small,
fontWeight: typography.fontWeight.bold,
lineHeight: 1,
marginBottom: spacing.small,
textTransform: 'uppercase',
}}
>
{props.componentBlock.label}
</NotEditable>
{editMode ? (
<Fragment>
<FormValue isValid={isValid} props={props.previewProps} onClose={onCloseEditMode} />
<div css={{ display: 'none' }}>{props.children}</div>
</Fragment>
) : (
<Fragment>
{props.renderedBlock}
<ChromefulToolbar
isValid={isValid}
onRemove={props.onRemove}
onShowEditMode={onShowEditMode}
props={props.previewProps}
/>
</Fragment>
)}
</div>
);
}

function DefaultToolbarWithChrome({
onShowEditMode,
onRemove,
isValid,
}: {
onShowEditMode(): void;
onRemove(): void;
props: any;
isValid: boolean;
}) {
const theme = useTheme();
return (
<ToolbarGroup as={NotEditable} marginTop="small">
<ToolbarButton
onClick={() => {
onShowEditMode();
}}
>
Edit
</ToolbarButton>
<ToolbarSeparator />
<Tooltip content="Remove" weight="subtle">
{attrs => (
<ToolbarButton
variant="destructive"
onClick={() => {
onRemove();
}}
{...attrs}
>
<Trash2Icon size="small" />
</ToolbarButton>
)}
</Tooltip>
{!isValid && (
<Fragment>
<ToolbarSeparator />
<span
css={{
color: theme.palette.red500,
display: 'flex',
alignItems: 'center',
paddingLeft: theme.spacing.small,
}}
>
Please edit the form, there are invalid fields.
</span>
</Fragment>
)}
</ToolbarGroup>
);
}

function FormValue({
onClose,
props,
isValid,
}: {
props: GenericPreviewProps<NonChildFieldComponentSchema, unknown>;
onClose(): void;
isValid: boolean;
}) {
const [forceValidation, setForceValidation] = useState(false);

return (
<Stack gap="xlarge" contentEditable={false}>
<FormValueContentFromPreviewProps {...props} forceValidation={forceValidation} />
<KeystoneUIButton
size="small"
tone="active"
weight="bold"
onClick={() => {
if (isValid) {
onClose();
} else {
setForceValidation(true);
}
}}
>
Done
</KeystoneUIButton>
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/** @jsxRuntime classic */
/** @jsx jsx */
import { jsx, useTheme } from '@keystone-ui/core';
import { Trash2Icon } from '@keystone-ui/icons/icons/Trash2Icon';
import { useControlledPopover } from '@keystone-ui/popover';
import { Tooltip } from '@keystone-ui/tooltip';
import { ReactNode } from 'react';
import { RenderElementProps } from 'slate-react';
import { InlineDialog, ToolbarButton } from '../primitives';
import { ComponentBlock, PreviewPropsForToolbar, ObjectField, ComponentSchema } from './api';

export function ChromelessComponentBlockElement(props: {
renderedBlock: ReactNode;
componentBlock: ComponentBlock & { chromeless: true };
previewProps: PreviewPropsForToolbar<ObjectField<Record<string, ComponentSchema>>>;
isOpen: boolean;
onRemove: () => void;
attributes: RenderElementProps['attributes'];
}) {
const { trigger, dialog } = useControlledPopover(
{ isOpen: props.isOpen, onClose: () => {} },
{ modifiers: [{ name: 'offset', options: { offset: [0, 8] } }] }
);
const { spacing } = useTheme();
const ChromelessToolbar = props.componentBlock.toolbar ?? DefaultToolbarWithoutChrome;
return (
<div
{...props.attributes}
css={{
marginBottom: spacing.xlarge,
marginTop: spacing.xlarge,
}}
>
<div {...trigger.props} ref={trigger.ref}>
{props.renderedBlock}
{props.isOpen && (
<InlineDialog {...dialog.props} ref={dialog.ref}>
<ChromelessToolbar onRemove={props.onRemove} props={props.previewProps} />
</InlineDialog>
)}
</div>
</div>
);
}

function DefaultToolbarWithoutChrome({
onRemove,
}: {
onRemove(): void;
props: Record<string, any>;
}) {
return (
<Tooltip content="Remove" weight="subtle">
{attrs => (
<ToolbarButton
variant="destructive"
onMouseDown={event => {
event.preventDefault();
onRemove();
}}
{...attrs}
>
<Trash2Icon size="small" />
</ToolbarButton>
)}
</Tooltip>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/** @jsxRuntime classic */
/** @jsx jsx */
import { jsx } from '@keystone-ui/core';
import React, { useContext } from 'react';
import { useMemo, ReactElement } from 'react';
import { Element } from 'slate';
import { ComponentBlock } from './api';
import { createGetPreviewProps, getKeysForArrayValue } from './preview-props';
import { ReadonlyPropPath } from './utils';

export const ChildrenByPathContext = React.createContext<Record<string, ReactElement>>({});

export function ChildFieldEditable({ path }: { path: readonly string[] }) {
const childrenByPath = useContext(ChildrenByPathContext);
const child = childrenByPath[JSON.stringify(path)];
if (child === undefined) {
return null;
}
return child;
}

export function ComponentBlockRender({
componentBlock,
element,
onChange,
children,
}: {
element: Element & { type: 'component-block' };
onChange: (cb: (props: Record<string, unknown>) => Record<string, unknown>) => void;
componentBlock: ComponentBlock;
children: any;
}) {
const getPreviewProps = useMemo(() => {
return createGetPreviewProps(
{ kind: 'object', fields: componentBlock.schema },
onChange,
path => <ChildFieldEditable path={path} />
);
}, [onChange, componentBlock]);

const previewProps = getPreviewProps(element.props);

const childrenByPath: Record<string, ReactElement> = {};
let maybeChild: ReactElement | undefined;
children.forEach((child: ReactElement) => {
const propPath = child.props.children.props.element.propPath;
if (propPath === undefined) {
maybeChild = child;
} else {
childrenByPath[JSON.stringify(propPathWithIndiciesToKeys(propPath, element.props))] = child;
}
});

const ComponentBlockPreview = componentBlock.preview;

return (
<ChildrenByPathContext.Provider value={childrenByPath}>
{useMemo(
() => (
<ComponentBlockPreview {...previewProps} />
),
[previewProps, ComponentBlockPreview]
)}
<span css={{ display: 'none' }}>{maybeChild}</span>
</ChildrenByPathContext.Provider>
);
}

// note this is written to avoid crashing when the given prop path doesn't exist in the value
// this is because editor updates happen asynchronously but we have some logic to ensure
// that updating the props of a component block synchronously updates it
// (this is primarily to not mess up things like cursors in inputs)
// this means that sometimes the child elements will be inconsistent with the values
// so to deal with this, we return a prop path this is "wrong" but won't break anything
function propPathWithIndiciesToKeys(propPath: ReadonlyPropPath, val: any): readonly string[] {
return propPath.map(key => {
if (typeof key === 'string') {
val = val?.[key];
return key;
}
if (!Array.isArray(val)) {
val = undefined;
return '';
}
const keys = getKeysForArrayValue(val);
val = val?.[key];
return keys[key];
});
}

This file was deleted.

Loading

0 comments on commit d591e31

Please sign in to comment.