diff --git a/Dockerfile b/Dockerfile index 843c44ce4..b488b245b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf COPY --from=builder /frontend-shared/build /usr/share/nginx/html COPY ./templates/index.html /usr/share/nginx/html/index.html COPY ./images /usr/share/nginx/html/images +COPY ./src/pattern-library/examples /usr/share/nginx/html/examples COPY conf/nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 5001 diff --git a/README.md b/README.md index c19677abc..a581da48d 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,4 @@ import { Link } from '@hypothesis/frontend-shared'; - [Development guide](docs/developing.md) - [Release guide](docs/releases.md) +- [Adding examples](docs/examples.md) diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 000000000..ddcdfe237 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,28 @@ +# Adding code examples + +Component library documentation frequently needs to show interactive examples, along with the code for them. + +Manually writing those code snippets has some issues: they are not covered by your type checking and linting tasks, and they can accidentally get outdated. + +The web-based documentation included with this library allows to create example files which are both used as regular modules that can be imported for interactive examples, but also read as plain text to generate their corresponding code snippet. + +These files are type-checked, formatted and linted like any other source files, and the code snippet will always be in sync with the interactive example. + +## Using example files + +When you want to create a code example, add a file with the name of your choice inside `src/pattern-library/examples` directory. + +You can then reference this file from the web-based pattern library, passing the `exampleFile` prop to the `` component. + +```tsx + +``` + +## Considerations + +There are some considerations and limitations when working with example files. + +- They all need to have the `tsx` extension. +- Nested directories are not supported, so it's a good idea to add contextual prefixes to example files. For example, all files related with `SelectNext` can be prefixed with `select-next` to make sure they are all grouped together. +- Example files need to have a Preact component as their default export. +- When generating the source code snippet, import statements are stripped out, to avoid internal module references which are not relevant for the library consumers. diff --git a/package.json b/package.json index eba13bf8c..c68ce7027 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@hypothesis/frontend-testing": "^1.2.2", "@rollup/plugin-babel": "^6.0.0", "@rollup/plugin-commonjs": "^25.0.0", + "@rollup/plugin-dynamic-import-vars": "^2.1.2", "@rollup/plugin-node-resolve": "^15.0.0", "@rollup/plugin-virtual": "^3.0.0", "@trivago/prettier-plugin-sort-imports": "^4.1.1", diff --git a/rollup.config.js b/rollup.config.js index d4a38b961..9b0a34936 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,5 +1,6 @@ import { babel } from '@rollup/plugin-babel'; import commonjs from '@rollup/plugin-commonjs'; +import dynamicImportVars from '@rollup/plugin-dynamic-import-vars'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import { string } from 'rollup-plugin-string'; @@ -27,6 +28,7 @@ function bundleConfig(name, entryFile) { exclude: 'node_modules/**', extensions: ['.js', '.ts', '.tsx'], }), + dynamicImportVars(), nodeResolve({ extensions: ['.js', '.ts', '.tsx'] }), commonjs({ include: 'node_modules/**' }), string({ diff --git a/scripts/serve-pattern-library.js b/scripts/serve-pattern-library.js index dfc9dc477..b52218c8a 100644 --- a/scripts/serve-pattern-library.js +++ b/scripts/serve-pattern-library.js @@ -11,6 +11,10 @@ export function servePatternLibrary(port = 4001) { app.use('/scripts', express.static(path.join(dirname, '../build/scripts'))); app.use('/styles', express.static(path.join(dirname, '../build/styles'))); app.use('/images', express.static(path.join(dirname, '../images'))); + app.use( + '/examples', + express.static(path.join(dirname, '../src/pattern-library/examples')), + ); // For any other path, serve the index.html file to allow client-side routing app.get('/:path?', (req, res) => { diff --git a/src/pattern-library/components/Library.tsx b/src/pattern-library/components/Library.tsx index 18a8b7766..d3ab59b44 100644 --- a/src/pattern-library/components/Library.tsx +++ b/src/pattern-library/components/Library.tsx @@ -1,7 +1,7 @@ import classnames from 'classnames'; import { toChildArray, createContext } from 'preact'; import type { ComponentChildren, JSX } from 'preact'; -import { useMemo, useState, useContext } from 'preact/hooks'; +import { useState, useContext, useEffect } from 'preact/hooks'; import { Link as RouteLink } from 'wouter-preact'; import { @@ -11,7 +11,7 @@ import { Scroll, ScrollContainer, } from '../../'; -import { jsxToHTML } from '../util/jsx-to-string'; +import { highlightCode, jsxToHTML } from '../util/jsx-to-string'; /** * Components for rendering component documentation, examples and demos in the @@ -165,8 +165,50 @@ function Example({ children, title, ...htmlAttributes }: LibraryExampleProps) { ); } +function SimpleError({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +/** + * Fetches provided example file and returns its contents as text, excluding + * the import statements. + * An error is thrown if the file cannot be fetched for any reason. + */ +async function fetchCodeExample( + exampleFile: string, + signal: AbortSignal, +): Promise { + const res = await fetch(`/examples/${exampleFile}.tsx`, { signal }); + if (res.status >= 400) { + throw new Error(`Failed loading ${exampleFile} example file`); + } + + const text = await res.text(); + + // Remove import statements and trim trailing empty lines + return text.replace(/^import .*;\n/gm, '').replace(/^\s*\n*/, ''); +} + export type LibraryDemoProps = { - children: ComponentChildren; + children?: ComponentChildren; + + /** + * Example file to read and use as content (to be rendered with syntax + * highlighting). + * It should be relative to the `pattern-library/examples` dir, and include no + * file extension: `exampleFile="some-example"`. + * + * The file needs to have a default export, which will be used to render the + * interactive example. + * + * If provided together with `children`, `children` will take precedence. + */ + exampleFile?: string; + /** Extra CSS classes for the demo content's immediate parent container */ classes?: string | string[]; /** Inline styles to apply to the demo container */ @@ -179,6 +221,62 @@ export type LibraryDemoProps = { withSource?: boolean; }; +type DemoContentsResult = + | { children: ComponentChildren } + | { + code?: string; + example?: ComponentChildren; + codeError?: string; + exampleError?: string; + }; + +function isStaticDemoContent( + contentResult: DemoContentsResult, +): contentResult is { children: ComponentChildren } { + return 'children' in contentResult; +} + +/** + * Determines what are the contents to be used for a Demo, which can be either + * an explicitly provided set of children, or the contents of an example file + * which is dynamically imported. + */ +function useDemoContents( + props: Pick, +): DemoContentsResult { + const [code, setCode] = useState(); + const [example, setExample] = useState(); + const [codeError, setCodeError] = useState(); + const [exampleError, setExampleError] = useState(); + + useEffect(() => { + if (!props.exampleFile) { + return () => {}; + } + + import(`../examples/${props.exampleFile}.tsx`) + .then(({ default: Example }) => setExample()) + .catch(() => + setExampleError( + `Failed loading ../examples/${props.exampleFile}.tsx module`, + ), + ); + + const controller = new AbortController(); + fetchCodeExample(props.exampleFile, controller.signal) + .then(setCode) + .catch(e => setCodeError(e.message)); + + return () => controller.abort(); + }, [props.exampleFile, props.children]); + + if (props.children) { + return { children: props.children }; + } + + return { code, example, codeError, exampleError }; +} + /** * Render a "Demo", with optional source. This will render the children as * provided in a tabbed container. If `withSource` is `true`, the JSX source @@ -186,25 +284,16 @@ export type LibraryDemoProps = { * rendered Demo content. */ function Demo({ - children, classes, withSource = false, style = {}, title, + ...rest }: LibraryDemoProps) { const [visibleTab, setVisibleTab] = useState('demo'); - const source = toChildArray(children).map((child, idx) => { - return ( -
  • - -
    -        
    -      
  • - ); - }); + const demoContents = useDemoContents(rest); + const isStaticContent = isStaticDemoContent(demoContents); + return (
    @@ -251,14 +340,41 @@ function Demo({ classes, )} > - {children} + {!isStaticContent ? demoContents.example : demoContents.children} + {!isStaticContent && demoContents.exampleError && ( + + )}
    )} {visibleTab === 'source' && ( -
    -
      {source}
    -
    + <> + {!isStaticContent ? ( + demoContents.code && + ) : ( +
    +
      + {toChildArray(demoContents.children).map((child, idx) => { + return ( +
    • + +
      +                        
      +                      
    • + ); + })} +
    +
    + )} + {!isStaticContent && demoContents.codeError && ( + + )} + )} @@ -332,14 +448,60 @@ function ChangelogItem({ status, children }: LibraryChangelogItemProps) { ); } +type CodeContentProps = + | { + /** Code content (to be rendered with syntax highlighting) */ + content: ComponentChildren; + } + | { + /** + * Example file to read and use as content (to be rendered with syntax + * highlighting) + */ + exampleFile: string; + }; + export type LibraryCodeProps = { - /** Code content (to be rendered with syntax highlighting) */ - content: ComponentChildren; /** Controls relative code font size */ size?: 'sm' | 'md'; /** Caption (e.g. filename, description) of code block */ title?: ComponentChildren; -}; +} & CodeContentProps; + +function isCodeWithContent( + props: CodeContentProps, +): props is { content: ComponentChildren } { + return 'content' in props; +} + +/** + * Dynamically resolves the content based on provided props. + * An error is optionally returned in case loading the content failed. + */ +function useCodeContent( + props: CodeContentProps, +): [string | undefined, Error | undefined] { + const hasStaticContent = isCodeWithContent(props); + const [codeMarkup, setCodeMarkup] = useState( + hasStaticContent ? jsxToHTML(props.content) : undefined, + ); + const [error, setError] = useState(); + + useEffect(() => { + if (hasStaticContent) { + return () => {}; + } + + const controller = new AbortController(); + fetchCodeExample(`/examples/${props.exampleFile}.tsx`, controller.signal) + .then(code => setCodeMarkup(highlightCode(code))) + .catch(setError); + + return () => controller.abort(); + }, [hasStaticContent, props]); + + return [codeMarkup, error]; +} /** * Render provided `content` as a "code block" example. @@ -347,33 +509,36 @@ export type LibraryCodeProps = { * Long code content will scroll if is rendered inside a parent * element with constrained dimensions. */ -function Code({ content, size, title }: LibraryCodeProps) { - const codeMarkup = useMemo(() => jsxToHTML(content), [content]); +function Code({ size, title, ...rest }: LibraryCodeProps) { + const [codeMarkup, error] = useCodeContent(rest); return (
    - -
    +
    + + +
    +              
    +            
    +          
    + {title && ( +
    + {title} +
    )} - > - - -
    -            
    -          
    -        
    - {title && ( -
    - {title} -
    - )} -
    + + )} + {error && }
    ); } diff --git a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx index ebd4bd089..42026ebb2 100644 --- a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx +++ b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx @@ -195,11 +195,11 @@ export default function SelectNextPage() { - -
    - -
    -
    +
    @@ -509,17 +509,11 @@ export default function SelectNextPage() { . Otherwise it is false - -
    -

    - When not using the popover API, the listbox will - be constrained by its container dimensions. -

    -
    - -
    -
    -
    + @@ -571,51 +565,6 @@ export default function SelectNextPage() { - - -

    - SelectNext is meant to be used as a controlled - component. -

    - - (); - return ( - - {value.name} -
    - {value.id} -
    - - ) : ( - <>Select one… - ) - } - > - {items.map(item => ( - - {() => ( - <> - {item.name} -
    -
    - {item.id} -
    - - )} - - ))} - - ); -}`} - /> - ); diff --git a/src/pattern-library/examples/select-next-basic.tsx b/src/pattern-library/examples/select-next-basic.tsx new file mode 100644 index 000000000..420948af9 --- /dev/null +++ b/src/pattern-library/examples/select-next-basic.tsx @@ -0,0 +1,34 @@ +import { useId, useState } from 'preact/hooks'; + +import { SelectNext } from '../..'; + +const items = [ + { id: '1', name: 'All students' }, + { id: '2', name: 'Albert Banana' }, + { id: '3', name: 'Bernard California' }, + { id: '4', name: 'Cecelia Davenport' }, + { id: '5', name: 'Doris Evanescence' }, +]; + +export default function App() { + const [value, setSelected] = useState<{ id: string; name: string }>(); + const selectId = useId(); + + return ( +
    + + Select one…} + > + {items.map(item => ( + + {item.name} + + ))} + +
    + ); +} diff --git a/src/pattern-library/examples/select-next-non-popover-listbox.tsx b/src/pattern-library/examples/select-next-non-popover-listbox.tsx new file mode 100644 index 000000000..d85c3ff31 --- /dev/null +++ b/src/pattern-library/examples/select-next-non-popover-listbox.tsx @@ -0,0 +1,70 @@ +import { useId, useState } from 'preact/hooks'; + +import { SelectNext } from '../..'; + +type ItemType = { + id: string; + name: string; + disabled?: boolean; +}; + +const items: ItemType[] = [ + { id: '1', name: 'All students' }, + { id: '2', name: 'Albert Banana' }, + { id: '3', name: 'Bernard California' }, + { id: '4', name: 'Cecelia Davenport' }, + { id: '5', name: 'Doris Evanescence' }, +]; + +export default function App() { + const [value, setValue] = useState(); + const buttonId = useId(); + + return ( +
    +

    + When not using the popover API, the listbox may be clipped + by parent elements that are styled to hide overflow. +

    +
    + <> + + +
    +
    {value.name}
    +
    + {value.id} +
    +
    + + ) : ( + <>Select one… + ) + } + > + {items.map(item => ( + + {item.name} +
    +
    + {item.id} +
    + + ))} + + +
    +
    + ); +} diff --git a/src/pattern-library/util/jsx-to-string.tsx b/src/pattern-library/util/jsx-to-string.tsx index b6a61fd5c..eee25e805 100644 --- a/src/pattern-library/util/jsx-to-string.tsx +++ b/src/pattern-library/util/jsx-to-string.tsx @@ -1,5 +1,5 @@ import hljs from 'highlight.js/lib/core'; -import hljsJavascriptLang from 'highlight.js/lib/languages/javascript'; +import hljsTypeScriptLang from 'highlight.js/lib/languages/typescript'; import hljsXMLLang from 'highlight.js/lib/languages/xml'; import { Fragment } from 'preact'; import type { ComponentChildren, VNode } from 'preact'; @@ -156,20 +156,34 @@ export function jsxToString(vnode: ComponentChildren): string { } /** - * Render a JSX expression as syntax-highlighted HTML markup. + * Render a code snippet as syntax-highlighted HTML markup. * * For the syntax highlighting to be visible, a Highlight.js CSS stylesheet must be * loaded on the page. + * + * The content returned by this function is sanitized and safe to use as + * `dangerouslySetInnerHTML` prop. */ -export function jsxToHTML(vnode: ComponentChildren): string { - // JSX support in Highlight.js involves a combination of the JS and XML +export function highlightCode(code: string): string { + // JSX support in Highlight.js involves a combination of the TS and XML // languages, so we need to load both. - if (!hljs.getLanguage('javascript')) { - hljs.registerLanguage('javascript', hljsJavascriptLang); + if (!hljs.getLanguage('typescript')) { + hljs.registerLanguage('typescript', hljsTypeScriptLang); } if (!hljs.getLanguage('xml')) { hljs.registerLanguage('xml', hljsXMLLang); } - const code = jsxToString(vnode); + return hljs.highlightAuto(code).value; } + +/** + * Render a JSX expression as syntax-highlighted HTML markup. + * + * The content returned by this function is sanitized and safe to use as + * `dangerouslySetInnerHTML` prop. + */ +export function jsxToHTML(vnode: ComponentChildren): string { + const code = jsxToString(vnode); + return highlightCode(code); +} diff --git a/yarn.lock b/yarn.lock index 0dc5af76f..fae80d21b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2148,6 +2148,7 @@ __metadata: "@hypothesis/frontend-testing": ^1.2.2 "@rollup/plugin-babel": ^6.0.0 "@rollup/plugin-commonjs": ^25.0.0 + "@rollup/plugin-dynamic-import-vars": ^2.1.2 "@rollup/plugin-node-resolve": ^15.0.0 "@rollup/plugin-virtual": ^3.0.0 "@trivago/prettier-plugin-sort-imports": ^4.1.1 @@ -2448,6 +2449,24 @@ __metadata: languageName: node linkType: hard +"@rollup/plugin-dynamic-import-vars@npm:^2.1.2": + version: 2.1.2 + resolution: "@rollup/plugin-dynamic-import-vars@npm:2.1.2" + dependencies: + "@rollup/pluginutils": ^5.0.1 + astring: ^1.8.5 + estree-walker: ^2.0.2 + fast-glob: ^3.2.12 + magic-string: ^0.30.3 + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 355dfc42cf1b0ddd009393927cdb9ea1d24cb674a9cdda732d0cb1320bb747c2f6d8e68f4dcb8507e37314ac916e8859e722f583371228bede140b5b83b67549 + languageName: node + linkType: hard + "@rollup/plugin-node-resolve@npm:^15.0.0": version: 15.2.3 resolution: "@rollup/plugin-node-resolve@npm:15.2.3" @@ -3355,6 +3374,15 @@ __metadata: languageName: node linkType: hard +"astring@npm:^1.8.5": + version: 1.8.6 + resolution: "astring@npm:1.8.6" + bin: + astring: bin/astring + checksum: 6f034d2acef1dac8bb231e7cc26c573d3c14e1975ea6e04f20312b43d4f462f963209bc64187d25d477a182dc3c33277959a0156ab7a3617aa79b1eac4d88e1f + languageName: node + linkType: hard + "async-done@npm:^2.0.0": version: 2.0.0 resolution: "async-done@npm:2.0.0" @@ -5670,6 +5698,19 @@ __metadata: languageName: node linkType: hard +"fast-glob@npm:^3.2.12": + version: 3.3.2 + resolution: "fast-glob@npm:3.3.2" + dependencies: + "@nodelib/fs.stat": ^2.0.2 + "@nodelib/fs.walk": ^1.2.3 + glob-parent: ^5.1.2 + merge2: ^1.3.0 + micromatch: ^4.0.4 + checksum: 900e4979f4dbc3313840078419245621259f349950411ca2fa445a2f9a1a6d98c3b5e7e0660c5ccd563aa61abe133a21765c6c0dec8e57da1ba71d8000b05ec1 + languageName: node + linkType: hard + "fast-glob@npm:^3.2.9": version: 3.2.12 resolution: "fast-glob@npm:3.2.12"