Skip to content

Commit

Permalink
Don't unmount RichTextContent whenever variant changes
Browse files Browse the repository at this point in the history
This consolidates the "outlined" vs "standard" DOM nodes/classes/styles
into a single "FieldContainer" component, rather than conditionally
rendering <OutlinedField /> or some other DOM node.  With
conditional-rendering approach, the `{content}` was being unmounted
whenever the variant changed, which could cause a visual glitch as all
Tiptap React node-views would have to re-render (so for instance, images
and heading nodes would be temporarily empty during the transition).
  • Loading branch information
sjdemartini committed Jul 1, 2023
1 parent 03bb699 commit afd49d8
Show file tree
Hide file tree
Showing 4 changed files with 75 additions and 65 deletions.
71 changes: 47 additions & 24 deletions src/OutlinedField.tsx → src/FieldContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,43 @@ import type React from "react";
import { makeStyles } from "tss-react/mui";
import { Z_INDEXES, getUtilityClasses } from "./styles";

export type OutlinedFieldClasses = ReturnType<typeof useStyles>["classes"];
export type FieldContainerClasses = ReturnType<typeof useStyles>["classes"];

export type OutlinedFieldProps = {
/** The content to render inside the outline. */
export type FieldContainerProps = {
/**
* Which style to use for the field. "outlined" shows a border around the children,
* which updates its appearance depending on hover/focus states, like MUI's
* OutlinedInput. "standard" does not include any outer border.
*/
variant?: "outlined" | "standard";
/** The content to render inside the container. */
children: React.ReactNode;
/** Class applied to the `root` element. */
className?: string;
/** Override or extend existing styles. */
classes?: Partial<OutlinedFieldClasses>;
classes?: Partial<FieldContainerClasses>;
focused?: boolean;
disabled?: boolean;
};

const outlinedFieldClasses: OutlinedFieldClasses = getUtilityClasses(
OutlinedField.name,
["root", "focused", "disabled", "notchedOutline"]
const fieldContainerClasses: FieldContainerClasses = getUtilityClasses(
FieldContainer.name,
["root", "outlined", "standard", "focused", "disabled", "notchedOutline"]
);

// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const useStyles = makeStyles<void, "notchedOutline">({
name: { OutlinedField },
name: { FieldContainer },
uniqId: "Os7ZPW", // https://docs.tss-react.dev/nested-selectors#ssr
})((theme, _params, classes) => {
// Based on the concept behind and styles of OutlinedInput and NotchedOutline
// styles here, to imitate outlined input appearance in material-ui
// https://github.com/mui-org/material-ui/blob/a4972c5931e637611f6421ed2a5cc3f78207cbb2/packages/material-ui/src/OutlinedInput/OutlinedInput.js#L9-L37
// https://github.com/mui/material-ui/blob/a4972c5931e637611f6421ed2a5cc3f78207cbb2/packages/material-ui/src/OutlinedInput/NotchedOutline.js
return {
root: {
root: {},

outlined: {
borderRadius: theme.shape.borderRadius,
padding: 1, //
position: "relative",
Expand All @@ -40,7 +48,10 @@ const useStyles = makeStyles<void, "notchedOutline">({
},
},

// Styles applied to the root element if the component is focused.
standard: {},

// Styles applied to the root element if the component is focused (if the
// `focused` prop is true).
focused: {
// Use && to trump &:hover above
[`&& .${classes.notchedOutline}`]: {
Expand All @@ -49,7 +60,8 @@ const useStyles = makeStyles<void, "notchedOutline">({
},
},

// Styles applied to the root element if the component is disabled.
// Styles applied to the root element if the component is disabled (if the
// `disabled` prop is true)
disabled: {
// Use && to trump &:hover above
[`&& .${classes.notchedOutline}`]: {
Expand All @@ -74,36 +86,47 @@ const useStyles = makeStyles<void, "notchedOutline">({
};
});

/** A component used to show an outline around a given field/input child. */
export default function OutlinedField({
/**
* Renders an element with classes and styles that correspond to the state and
* style-variant of a user-input field, the content of which should be passed in as
* `children`.
*/
export default function FieldContainer({
variant,
children,
focused,
disabled,
classes: overrideClasses = {},
className,
}: OutlinedFieldProps) {
}: FieldContainerProps) {
const { classes, cx } = useStyles(undefined, {
props: { classes: overrideClasses },
});

return (
<div
className={cx(
outlinedFieldClasses.root,
fieldContainerClasses.root,
classes.root,
focused && [outlinedFieldClasses.focused, classes.focused],
disabled && [outlinedFieldClasses.disabled, classes.disabled],
focused && [fieldContainerClasses.focused, classes.focused],
disabled && [fieldContainerClasses.disabled, classes.disabled],
variant === "outlined"
? [fieldContainerClasses.outlined, classes.outlined]
: [fieldContainerClasses.standard, classes.standard],
className
)}
>
{children}
<div
className={cx(
outlinedFieldClasses.notchedOutline,
classes.notchedOutline
)}
aria-hidden
/>

{variant === "outlined" && (
<div
className={cx(
fieldContainerClasses.notchedOutline,
classes.notchedOutline
)}
aria-hidden
/>
)}
</div>
);
}
63 changes: 25 additions & 38 deletions src/RichTextField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { makeStyles } from "tss-react/mui";
import FieldContainer from "./FieldContainer";
import MenuBar, { type MenuBarProps } from "./MenuBar";
import OutlinedField from "./OutlinedField";
import RichTextContent, { type RichTextContentProps } from "./RichTextContent";
import { useRichTextEditorContext } from "./context";
import useDebouncedFocus from "./hooks/useDebouncedFocus";
Expand All @@ -10,7 +10,11 @@ import DebounceRender from "./utils/DebounceRender";
export type RichTextFieldClasses = ReturnType<typeof useStyles>["classes"];

export type RichTextFieldProps = {
/** Which style to use for */
/**
* Which style to use for the field. "outlined" shows a border around the controls,
* editor, and footer, which updates depending on hover/focus states, like MUI's
* OutlinedInput. "standard" does not include any outer border.
*/
variant?: "outlined" | "standard";
/** Class applied to the root element. */
className?: string;
Expand Down Expand Up @@ -119,14 +123,25 @@ export default function RichTextField({
});
const editor = useRichTextEditorContext();

// Because the user interactions with the editor menu bar buttons unfocus the
// editor (since it's not part of the editor content), we'll debounce our
// visual focused state of the OutlinedField so that it doesn't "flash" when
// that happens
const isOutlinedFieldFocused = useDebouncedFocus({ editor });
// Because the user interactions with the editor menu bar buttons unfocus the editor
// (since it's not part of the editor content), we'll debounce our visual focused
// state so that the (outlined) field focus styles don't "flash" whenever that happens
const isFieldFocused = useDebouncedFocus({ editor });

const content = (
<>
return (
<FieldContainer
variant={variant}
focused={!disabled && isFieldFocused}
disabled={disabled}
className={cx(
richTextFieldClasses.root,
classes.root,
variant === "outlined"
? [richTextFieldClasses.outlined, classes.outlined]
: [richTextFieldClasses.standard, classes.standard],
className
)}
>
{controls && (
<MenuBar
{...MenuBarProps}
Expand Down Expand Up @@ -162,34 +177,6 @@ export default function RichTextField({
/>

{footer}
</>
);

return variant === "outlined" ? (
<OutlinedField
focused={!disabled && isOutlinedFieldFocused}
disabled={disabled}
className={cx(
richTextFieldClasses.root,
classes.root,
richTextFieldClasses.outlined,
classes.outlined,
className
)}
>
{content}
</OutlinedField>
) : (
<div
className={cx(
richTextFieldClasses.root,
classes.root,
richTextFieldClasses.standard,
classes.standard,
className
)}
>
{content}
</div>
</FieldContainer>
);
}
2 changes: 1 addition & 1 deletion src/hooks/useDebouncedFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type UseDebouncedFocusOptions = {
* menu bar buttons.
*
* This is useful for showing the focus state visually, as with the `focused`
* prop of <OutlinedField />.
* prop of <FieldContainer />.
*/
export default function useDebouncedFocus({
editor,
Expand Down
4 changes: 2 additions & 2 deletions src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export const Z_INDEXES = {
// The menu bar must sit higher than the table components (like the
// column-resize-handle and selectedCells) of the editor.
MENU_BAR: 2,
// The notched outline of the OutlinedField should be at the same z-index as
// the menu-bar, so that it can contain/enclose it
// The notched outline of the "outlined" field variant should be at the same z-index
// as the menu-bar, so that it can contain/enclose it
NOTCHED_OUTLINE: 2,
// The bubble menus should appear on top of the menu bar
BUBBLE_MENU: 3,
Expand Down

0 comments on commit afd49d8

Please sign in to comment.