Skip to content

Commit

Permalink
Editor popover improvements (#779)
Browse files Browse the repository at this point in the history
  • Loading branch information
jossmac authored Nov 24, 2023
1 parent 16bd706 commit 0ca7f47
Show file tree
Hide file tree
Showing 11 changed files with 373 additions and 137 deletions.
8 changes: 8 additions & 0 deletions .changeset/chilled-plums-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@keystatic/core': patch
---

Markdoc editor popover improvements:

- boundary respected by popovers
- popovers tethered to reference regardless of type (node/range/virtual) + better perf
6 changes: 6 additions & 0 deletions .changeset/eleven-dragons-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@keystar/ui': patch
---

Support "boundary" + "portal" props on `EditorPopover` component. Simulate
clipping for portal'd popovers.
101 changes: 85 additions & 16 deletions design-system/pkg/src/editor/EditorPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import {
Fragment,
HTMLProps,
ReactNode,
forwardRef,
useImperativeHandle,
useState,
} from 'react';
import {
Boundary,
ContextData,
FloatingPortal,
Middleware,
MiddlewareState,
Placement,
ReferenceElement,
autoUpdate,
flip,
inline,
hide,
limitShift,
offset,
shift,
Expand All @@ -29,14 +32,27 @@ import {
} from '@keystar/ui/style';

export type EditorPopoverProps = {
/**
* How the popover should adapt when constrained by available space.
* @default 'flip'
*/
adaptToBoundary?: 'flip' | 'stick' | 'stretch';
/**
* The clipping boundary area of the floating element.
* @default 'clippingAncestors'
*/
boundary?: Boundary;
/** The contents of the floating element. */
children: ReactNode;
reference: ReferenceElement;
/** The placement of the floating element relative to the reference element. */
placement?: Placement;
/**
* How the popover should adapt when constrained by available space in the viewport.
* @default 'flip'
* Whether to portal the floating element outside the DOM hierarchy of the parent component.
* @default true
*/
adaptToViewport?: 'flip' | 'stick' | 'stretch';
portal?: boolean;
/** The reference element that the floating element should be positioned relative to. */
reference: ReferenceElement;
} & Pick<
BaseStyleProps,
| 'height'
Expand All @@ -53,7 +69,9 @@ export type EditorPopoverRef = { context: ContextData; update: () => void };

export const EditorPopover = forwardRef<EditorPopoverRef, EditorPopoverProps>(
function EditorPopover(props, forwardedRef) {
const { children, reference, placement = 'bottom' } = props;
props = useDefaultProps(props);
const { children, reference, placement, portal } = props;
const Wrapper = portal ? FloatingPortal : Fragment;

const styleProps = useStyleProps(props);
const [floating, setFloating] = useState<HTMLDivElement | null>(null);
Expand All @@ -74,63 +92,114 @@ export const EditorPopover = forwardRef<EditorPopoverRef, EditorPopoverProps>(
);

return (
<FloatingPortal>
<Wrapper>
<DialogElement
ref={setFloating}
{...styleProps}
style={{ ...floatingStyles, ...styleProps.style }}
>
{children}
</DialogElement>
</FloatingPortal>
</Wrapper>
);
}
);

// Utils
// ------------------------------

function useDefaultProps(props: EditorPopoverProps) {
return Object.assign(
{},
{
adaptToBoundary: 'flip',
placement: 'bottom',
portal: true,
},
props
);
}

export const DEFAULT_OFFSET = 8;

/**
* Watch for values returned from other middlewares and apply the appropriate
* styles to the floating element.
*/
function applyStyles(): Middleware {
return {
name: 'applyStyles',
async fn(state: MiddlewareState) {
let { elements, middlewareData } = state;

if (middlewareData.hide) {
Object.assign(elements.floating.style, {
visibility: middlewareData.hide.referenceHidden
? 'hidden'
: 'visible',
});
}

return {};
},
};
}

export function getMiddleware(
props: EditorPopoverProps
): Array<Middleware | null | undefined | false> {
const { adaptToViewport } = props;
const { adaptToBoundary, boundary } = props;

// simulate clipping for portaled popovers
let portalMiddlewares = [
...(props.portal ? [hide({ boundary })] : []),
applyStyles(),
];

if (adaptToViewport === 'stick') {
// stick to the boundary
if (adaptToBoundary === 'stick') {
return [
offset(DEFAULT_OFFSET),
shift({
boundary,
crossAxis: true,
padding: DEFAULT_OFFSET,
limiter: limitShift({
offset: ({ rects }) => ({
crossAxis: rects.floating.height,
offset: ({ rects, middlewareData, placement }) => ({
crossAxis:
rects.floating.height +
(middlewareData.offset?.y ?? 0) * (placement === 'top' ? -1 : 1),
}),
}),
}),
...portalMiddlewares,
];
}
if (adaptToViewport === 'stretch') {

// stretch to fill
if (adaptToBoundary === 'stretch') {
return [
flip(),
offset(DEFAULT_OFFSET),
flip({ boundary, padding: DEFAULT_OFFSET }),
size({
apply({ elements, availableHeight }) {
Object.assign(elements.floating.style, {
maxHeight: `${availableHeight}px`,
});
},
boundary,
padding: DEFAULT_OFFSET,
}),
...portalMiddlewares,
];
}

// default: flip
return [
offset(DEFAULT_OFFSET),
flip({ padding: DEFAULT_OFFSET }),
flip({ boundary, padding: DEFAULT_OFFSET }),
shift({ padding: DEFAULT_OFFSET }),
inline(),
...portalMiddlewares,
];
}

Expand Down
48 changes: 2 additions & 46 deletions design-system/pkg/src/editor/stories/EditorListbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { action } from '@keystar/ui-storybook';
import { Key, ReactElement, useEffect, useRef, useState } from 'react';
import { Key, ReactElement, useEffect, useRef } from 'react';

import { Icon } from '@keystar/ui/icon';
import { fileCodeIcon } from '@keystar/ui/icon/icons/fileCodeIcon';
Expand All @@ -15,10 +15,9 @@ import { listOrderedIcon } from '@keystar/ui/icon/icons/listOrderedIcon';
import { quoteIcon } from '@keystar/ui/icon/icons/quoteIcon';
import { tableIcon } from '@keystar/ui/icon/icons/tableIcon';
import { separatorHorizontalIcon } from '@keystar/ui/icon/icons/separatorHorizontalIcon';
import { Box } from '@keystar/ui/layout';
import { Kbd, KbdProps, Text } from '@keystar/ui/typography';

import { EditorListbox, EditorPopover, Item, Section } from '..';
import { EditorListbox, Item, Section } from '..';

type KbdOption = 'alt' | 'meta' | 'shift';
type KbdOptions = KbdOption[];
Expand Down Expand Up @@ -109,49 +108,6 @@ ComplexItems.story = {
name: 'complex items',
};

export const WithinPopover = () => {
let listenerRef = useListenerRef();
let [triggerRef, setTriggerRef] = useState<HTMLElement | null>(null);

return (
<>
<Box
paddingX="medium"
ref={setTriggerRef}
UNSAFE_style={{
marginBottom: 600,
marginTop: 600,
marginInlineStart: 300,
}}
>
<Text color="accent" weight="medium">
/insert-menu
</Text>
</Box>
{triggerRef && (
<EditorPopover
reference={triggerRef}
placement="bottom-start"
adaptToViewport="stretch"
minHeight="alias.singleLineWidth"
>
<EditorListbox
aria-label="popover example"
items={complexItems[0].children}
children={childRenderer}
listenerRef={listenerRef}
UNSAFE_style={{ width: 320 }}
/>
</EditorPopover>
)}
</>
);
};

WithinPopover.story = {
name: 'within popover',
};

// Utils
// -----------------------------------------------------------------------------

Expand Down
Loading

2 comments on commit 0ca7f47

@vercel
Copy link

@vercel vercel bot commented on 0ca7f47 Nov 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

keystatic – ./dev-projects/next-app

keystatic-git-main-thinkmill-labs.vercel.app
keystatic.vercel.app
keystatic-thinkmill-labs.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 0ca7f47 Nov 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.