diff --git a/package.json b/package.json index 501b08804..5999b8808 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "react-aria": "^3.6.0", "react-day-picker": "^7.4.10", "react-stately": "^3.5.0", + "tributejs": "^5.1.3", + "trix": "^1.3.1", "framer-motion": "^4.1.11" }, "peerDependencies": { diff --git a/src/Css.ts b/src/Css.ts index 97b107b90..fa751d68c 100644 --- a/src/Css.ts +++ b/src/Css.ts @@ -812,7 +812,8 @@ class CssBuilder { get childGap7() { return this.childGap(7); } get childGap8() { return this.childGap(8); } childGap(inc: number | string) { - const p = this.opts.rules["flexDirection"] === "column" ? "marginTop" : "marginLeft"; + const direction = this.opts.rules["flexDirection"]; + const p = direction === "column" ? "marginTop" : direction === "column-reverse" ? "marginBottom" : "marginLeft"; return this.addIn("& > * + *", Css.add(p, maybeInc(inc)).important.$); } diff --git a/src/components/RichTextField.stories.tsx b/src/components/RichTextField.stories.tsx new file mode 100644 index 000000000..fd079ff94 --- /dev/null +++ b/src/components/RichTextField.stories.tsx @@ -0,0 +1,32 @@ +import { Meta } from "@storybook/react"; +import { useState } from "react"; +import { RichTextField as RichTextFieldComponent } from "src/components/RichTextField"; + +export default { + component: RichTextFieldComponent, + title: "Components/Rich Text Field", +} as Meta; + +export function RichTextField() { + return ; +} + +function TestField() { + const [value, setValue] = useState(); + const [tags, setTags] = useState([]); + return ( + <> + { + setValue(html); + setTags(tags); + }} + mergeTags={["foo", "bar", "zaz"]} + /> +
value: {value === undefined ? "undefined" : value}
+
tags: {tags.join(", ")}
+ + ); +} diff --git a/src/components/RichTextField.tsx b/src/components/RichTextField.tsx new file mode 100644 index 000000000..934f6f4cb --- /dev/null +++ b/src/components/RichTextField.tsx @@ -0,0 +1,175 @@ +import { Global } from "@emotion/react"; +import { ChangeEvent, createElement, useEffect, useRef } from "react"; +import { useId } from "react-aria"; +import { Label } from "src/components/Label"; +import { Css, Palette } from "src/Css"; +import Tribute from "tributejs"; +import "tributejs/dist/tribute.css"; +import "trix/dist/trix"; +import "trix/dist/trix.css"; + +export interface RichTextFieldProps { + /** The initial html value to show in the trix editor. */ + value: string | undefined; + onChange: (html: string | undefined, text: string | undefined, mergeTags: string[]) => void; + /** + * A list of tags/names to show in a popup when the user `@`-s. + * + * Currently we don't support mergeTags being updated. + */ + mergeTags?: string[]; + label?: string; + autoFocus?: boolean; + placeholder?: string; +} + +// There aren't types for trix, so add our own. For now `loadHTML` is all we call anyway. +type Editor = { + loadHTML(html: string): void; +}; + +/** + * Provides a simple rich text editor based on trix. + * + * See [trix]{@link https://github.com/basecamp/trix}. + * + * We also integrate [tributejs]{@link https://github.com/zurb/tribute} for @ mentions. + */ +export function RichTextField(props: RichTextFieldProps) { + const { mergeTags, label, value, onChange } = props; + const id = useId(); + + // We get a reference to the Editor instance after trix-init fires + const editor = useRef(undefined); + + // Keep track of what we pass to onChange, so that we can make ourselves keep looking + // like a controlled input, i.e. by only calling loadHTML if a new incoming `value` !== `currentHtml`, + // otherwise we'll constantly call loadHTML and reset the user's cursor location. + const currentHtml = useRef(undefined); + + // Use a ref for onChange b/c so trixChange always has the latest + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect( + () => { + const editorElement = document.getElementById(`editor-${id}`); + if (!editorElement) { + throw new Error("editorElement not found"); + } + + editor.current = (editorElement as any).editor; + if (!editor.current) { + throw new Error("editor not found"); + } + if (mergeTags !== undefined) { + attachTributeJs(mergeTags, editorElement!); + } + + // We have a 2nd useEffect to call loadHTML when value changes, but + // we do this here b/c we assume the 2nd useEffect's initial evaluation + // "missed" having editor.current set b/c trix-initialize hadn't fired. + currentHtml.current = value; + editor.current.loadHTML(value || ""); + + function trixChange(e: ChangeEvent) { + const { textContent, innerHTML } = e.target; + const onChange = onChangeRef.current; + // If the user only types whitespace, treat that as undefined + if ((textContent || "").trim() === "") { + currentHtml.current = undefined; + onChange && onChange(undefined, undefined, []); + } else { + currentHtml.current = innerHTML; + const mentions = extractIdsFromMentions(mergeTags || [], textContent || ""); + onChange && onChange(innerHTML, textContent || undefined, mentions); + } + } + + editorElement.addEventListener("trix-change", trixChange as any, false); + return () => { + editorElement.removeEventListener("trix-change", trixChange as any); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + useEffect(() => { + // If our value prop changes (without the change coming from us), reload it + if (editor.current && value !== currentHtml.current) { + editor.current.loadHTML(value || ""); + } + }, [value]); + + const { placeholder, autoFocus } = props; + + return ( +
+ {/* TODO: Not sure what to pass to labelProps. */} + {label &&
+ ); +} + +function attachTributeJs(mergeTags: string[], editorElement: HTMLElement) { + const values = mergeTags.map((value) => ({ value })); + const tribute = new Tribute({ + trigger: "@", + lookup: "value", + allowSpaces: true, + /** {@link https://github.com/zurb/tribute#hide-menu-when-no-match-is-returned} */ + noMatchTemplate: () => ``, + selectTemplate: ({ original: { value } }) => `@${value}`, + values, + }); + // In dev mode, this fails because jsdom doesn't support contentEditable. Note that + // before create-react-app 4.x / a newer jsdom, the trix-initialize event wasn't + // even fired during unit tests anyway. + try { + tribute.attach(editorElement!); + } catch {} +} + +const trixCssOverrides = { + ...Css.relative.add({ wordBreak: "break-word" }).$, + // Put the toolbar on the bottom + ...Css.df.flexColumnReverse.childGap1.$, + // Some basic copy/paste from TextFieldBase + "& trix-editor": Css.bgWhite.sm.gray900.br4.bGray300.$, + "& trix-editor:focus": Css.bLightBlue700.$, + // Make the buttons closer to ours + "& .trix-button": Css.bgWhite.sm.$, + // We don't support file attachment yet, so hide that control for now. + "& .trix-button-group--file-tools": Css.dn.$, + // Other things that are unused and we want to hide + "& .trix-button--icon-heading-1": Css.dn.$, + "& .trix-button--icon-code": Css.dn.$, + "& .trix-button--icon-quote": Css.dn.$, + "& .trix-button--icon-increase-nesting-level": Css.dn.$, + "& .trix-button--icon-decrease-nesting-level": Css.dn.$, + "& .trix-button-group--history-tools": Css.dn.$, + // Put back list styles that CssReset is probably too aggressive with + "& ul": Css.ml2.add("listStyleType", "disc").$, + "& ol": Css.ml2.add("listStyleType", "decimal").$, +}; + +// Style the @ mention box +const tributeOverrides = { + ".tribute-container": Css.add({ minWidth: "300px" }).$, + ".tribute-container > ul": Css.sm.bgWhite.ba.br4.bLightBlue700.overflowHidden.$, +}; + +function extractIdsFromMentions(mergeTags: string[], content: string): string[] { + return mergeTags.filter((tag) => content.includes(`@${tag}`)); +} diff --git a/src/forms/BoundRichTextField.tsx b/src/forms/BoundRichTextField.tsx new file mode 100644 index 000000000..24bf2278f --- /dev/null +++ b/src/forms/BoundRichTextField.tsx @@ -0,0 +1,32 @@ +import { FieldState } from "@homebound/form-state"; +import { Observer } from "mobx-react"; +import { RichTextField, RichTextFieldProps } from "src/components/RichTextField"; +import { useTestIds } from "src/utils"; +import { defaultLabel } from "src/utils/defaultLabel"; + +export type BoundRichTextFieldProps = Omit & { + field: FieldState; + // Optional in case the page wants extra behavior + onChange?: (value: string | undefined) => void; +}; + +/** Wraps `RichTextField` and binds it to a form field. */ +export function BoundRichTextField(props: BoundRichTextFieldProps) { + const { field, onChange = (value) => field.set(value), label = defaultLabel(field.key), ...others } = props; + const testId = useTestIds(props, field.key); + return ( + + {() => ( + field.blur()} + {...testId} + {...others} + /> + )} + + ); +} diff --git a/truss/index.ts b/truss/index.ts index 66a046cba..bbf969815 100644 --- a/truss/index.ts +++ b/truss/index.ts @@ -1,4 +1,4 @@ -import { generate, newMethod, newIncrementDelegateMethods, newMethodsForProp, Sections } from "@homebound/truss"; +import { generate, newIncrementDelegateMethods, newMethod, newMethodsForProp, Sections } from "@homebound/truss"; import { palette } from "./palette"; const increment = 8; @@ -30,7 +30,9 @@ const fonts: Record `${property} 200ms`).join(", "); +const transition: string = ["background-color", "border-color", "box-shadow", "left", "right"] + .map((property) => `${property} 200ms`) + .join(", "); // Custom rules const sections: Sections = { @@ -64,7 +66,8 @@ const sections: Sections = { childGap: (config) => [ ...newIncrementDelegateMethods("childGap", config.numberOfIncrements), `childGap(inc: number | string) { - const p = this.opts.rules["flexDirection"] === "column" ? "marginTop" : "marginLeft"; + const direction = this.opts.rules["flexDirection"]; + const p = direction === "column" ? "marginTop" : direction === "column-reverse" ? "marginBottom" : "marginLeft"; return this.addIn("& > * + *", Css.add(p, maybeInc(inc)).important.$); }`, ], diff --git a/yarn.lock b/yarn.lock index 0ecfcfe69..162ef499b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8515,11 +8515,6 @@ he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -hey-listen@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" - integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== - header-case@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/header-case/-/header-case-2.0.4.tgz#5a42e63b55177349cf405beb8d775acabb92c063" @@ -8528,6 +8523,11 @@ header-case@^2.0.4: capital-case "^1.0.4" tslib "^2.0.3" +hey-listen@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" + integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== + highlight.js@^10.1.1, highlight.js@~10.7.0: version "10.7.2" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.2.tgz#89319b861edc66c48854ed1e6da21ea89f847360" @@ -14810,6 +14810,11 @@ treeverse@^1.0.4: resolved "https://registry.yarnpkg.com/treeverse/-/treeverse-1.0.4.tgz#a6b0ebf98a1bca6846ddc7ecbc900df08cb9cd5f" integrity sha512-whw60l7r+8ZU8Tu/Uc2yxtc4ZTZbR/PF3u1IPNKGQ6p8EICLb3Z2lAgoqw9bqYd8IkgnsaOcLzYHFckjqNsf0g== +tributejs@^5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-5.1.3.tgz#980600fc72865be5868893078b4bfde721129eae" + integrity sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ== + trim-newlines@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.0.tgz#79726304a6a898aa8373427298d54c2ee8b1cb30" @@ -14830,6 +14835,11 @@ trim@0.0.1: resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" integrity sha1-WFhUf2spB1fulczMZm+1AITEYN0= +trix@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/trix/-/trix-1.3.1.tgz#ccce8d9e72bf0fe70c8c019ff558c70266f8d857" + integrity sha512-BbH6mb6gk+AV4f2as38mP6Ucc1LE3OD6XxkZnAgPIduWXYtvg2mI3cZhIZSLqmMh9OITEpOBCCk88IVmyjU7bA== + trough@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406"