diff --git a/.plop/templates/component/component.mdx.hbs b/.plop/templates/component/component.mdx.hbs new file mode 100644 index 00000000..afea35cb --- /dev/null +++ b/.plop/templates/component/component.mdx.hbs @@ -0,0 +1,113 @@ +{{!-- Template-authoring note, stripped from the generated file: JSX braces that touch +Handlebars braces form a triple-stache and break generation. Keep a space inside +`of={ … }` and let `~` trim it from the output, e.g. `of={ {{~pascalCase name~}} Stories}`. +Avoid inline `style={{ … }}` in this file for the same reason. +Markdown tables don't render in this repo's MDX (no remark-gfm); wrap them in a +`{` … `}` block and escape inner backticks as `\``, like avatar.mdx. --}} +import { Canvas, Controls, Markdown, Meta, Subtitle, Title } from '@storybook/addon-docs/blocks' + +import * as {{pascalCase name}}Stories from './{{dashCase name}}.stories' + + + + + +<Subtitle>One-line summary of what {{pascalCase name}} is for.</Subtitle> + +## Overview + +What {{pascalCase name}} is and the problem it solves, in a sentence or two. For design +guidance (anatomy, when to use, do and don't), see the +[{{pascalCase name}} handbook page](https://github.com/Doist/component-libraries/blob/main/handbook/components/{{dashCase name}}.md). + +{/* Verify this handbook link: the slug isn't always the component's dash-name (e.g. + buttons.md, menus.md, switches.md) and some pages don't exist yet. */} + +{/* Compound components (multiple exported parts): describe the parts in an Anatomy table. + +## Anatomy + +<Markdown>{` +| Part | Renders | Owns | +| --- | --- | --- | +| \`<{{pascalCase name}}>\` | the root element | context and coordination between parts | +| \`<{{pascalCase name}}.Item>\` | one item | its own selection state | +`}</Markdown> +*/} + +## When to use + +{/* Two or more concrete scenarios. Trim to what the handbook doesn't already cover. */} + +- Use {{pascalCase name}} when ... +- Prefer another component when ... + +## Basic usage + +The simplest real example. Pass `variant` and `children`. + +<Canvas of={ {{~pascalCase name~}} Stories.Default} /> + +## Variants + +{{pascalCase name}} supports a `primary` and a `secondary` variant. + +<Canvas of={ {{~pascalCase name~}} Stories.Variants} /> + +{/* Visual states (idle, hover, disabled, loading) get their own section. Add a States + story, then uncomment: + +## States + +<Canvas of={ {{~pascalCase name~}} Stories.States} /> +*/} + +## Props + +Try the props in the playground below; the table lists each prop with its type and default. + +<Canvas of={ {{~pascalCase name~}} Stories.Playground} /> + +<Controls of={ {{~pascalCase name~}} Stories.Playground} /> + +{/* Compound components: document each exported part with its own block. Import the parts + from the component module (e.g. `import { {{pascalCase name}}Item } from './{{dashCase name}}'`), + add `ArgTypes` and `Description` to the blocks import above, then repeat per part: + +### `<{{pascalCase name}}.Item>` + +<Description of={ {{~pascalCase name~}} Item} /> + +<ArgTypes of={ {{~pascalCase name~}} Item} /> +*/} + +## Custom properties + +These CSS custom properties control the {{pascalCase name}} colors. The variant classes set +them; the values below are what the `primary` variant sets. + +<Markdown>{` +| Property | Default (primary variant) | Purpose | +| --- | --- | --- | +| \`--reactist-{{dashCase name}}-tint\` | \`var(--reactist-content-primary)\` | Content color | +| \`--reactist-{{dashCase name}}-fill\` | \`var(--reactist-framework-fill-selected)\` | Background color | +`}</Markdown> + +{/* Theming variables set on `:root` with literal defaults belong in this table too. For + long lists of color variables, a ColorPalette (see avatar.mdx) works better. */} + +{/* If integration responsibilities deliberately stay with the consumer (data fetching, + persistence, entity conventions), make the boundary explicit: + +## What the consumer owns + +- **Some responsibility**: what the component expects the consumer to handle, and why. +*/} + +## Accessibility + +- **Keyboard**: describe focus order and keys the component handles. +- **Screen reader**: the name, role, and state it exposes (and via which prop). +- **Focus**: focus management / trapping behavior, if any. + +<Canvas of={ {{~pascalCase name~}} Stories.Accessibility} /> diff --git a/.plop/templates/component/component.stories.tsx.hbs b/.plop/templates/component/component.stories.tsx.hbs index fd7b7bb5..3e93cba9 100644 --- a/.plop/templates/component/component.stories.tsx.hbs +++ b/.plop/templates/component/component.stories.tsx.hbs @@ -1,10 +1,11 @@ import * as React from 'react' -import type { Meta, StoryObj } from '@storybook/react' import { {{pascalCase name}} } from './{{dashCase name}}' +import type { Meta, StoryObj } from '@storybook/react-vite' + const meta = { - title: 'Design system/{{pascalCase name}}', + title: '{{category}}/{{pascalCase name}}', component: {{pascalCase name}}, parameters: { /** @@ -12,6 +13,8 @@ const meta = { * Used to highlight the web accessibility standards the component meets or * whether the component is deprecated. * Possible values: `accessible`, `partiallyAccessible`, `notAccessible`, `deprecated` + * Start at `notAccessible`; raise it only once the component has been through an + * accessibility review, so a fresh component never claims a standard it hasn't earned. * @see .storybook/badges/README.md */ badges: ['notAccessible'], @@ -34,6 +37,39 @@ export default meta type Story = StoryObj<typeof meta> +export const Default: Story = { + render() { + return ( + <section> + <{{pascalCase name}} variant="primary">Hello world</{{pascalCase name}}> + </section> + ) + }, +} + +export const Variants: Story = { + render() { + return ( + <section> + <{{pascalCase name}} variant="primary">Primary</{{pascalCase name}}>{' '} + <{{pascalCase name}} variant="secondary">Secondary</{{pascalCase name}}> + </section> + ) + }, +} + +export const Accessibility: Story = { + // Demonstrate what the docs page's Accessibility section describes (the + // accessible name, keyboard behavior, decorative usage), not just default content. + render() { + return ( + <section> + <{{pascalCase name}} variant="primary">Accessible content</{{pascalCase name}}> + </section> + ) + }, +} + export const Playground: Story = { args: { variant: 'primary', @@ -48,7 +84,7 @@ export const Playground: Story = { control: { type: 'text' }, }, }, - render(args) { + render(args: React.ComponentProps<typeof {{pascalCase name}}>) { return ( <section> <{{pascalCase name}} {...args} /> diff --git a/.plop/templates/component/component.test.tsx.hbs b/.plop/templates/component/component.test.tsx.hbs index dbe0405a..e91960a1 100644 --- a/.plop/templates/component/component.test.tsx.hbs +++ b/.plop/templates/component/component.test.tsx.hbs @@ -1,5 +1,7 @@ import * as React from 'react' + import { render, screen } from '@testing-library/react' + import { {{pascalCase name}} } from './{{dashCase name}}' describe('{{pascalCase name}}', () => { diff --git a/.plop/templates/component/component.tsx.hbs b/.plop/templates/component/component.tsx.hbs index f1da8df3..a42def26 100644 --- a/.plop/templates/component/component.tsx.hbs +++ b/.plop/templates/component/component.tsx.hbs @@ -1,5 +1,7 @@ import * as React from 'react' + import { Box } from '../box' + import styles from './{{dashCase name}}.module.css' type {{pascalCase name}}Props = { @@ -14,11 +16,7 @@ type {{pascalCase name}}Props = { } function {{pascalCase name}}({ children, variant }: {{pascalCase name}}Props) { - return ( - <Box className={[styles.container, styles[variant]]}> - {children} - </Box> - ) + return <Box className={[styles.container, styles[variant]]}>{children}</Box> } export { {{pascalCase name}} } diff --git a/.plop/templates/component/index.ts.hbs b/.plop/templates/component/index.ts.hbs index 45e186b9..cd311cbf 100644 --- a/.plop/templates/component/index.ts.hbs +++ b/.plop/templates/component/index.ts.hbs @@ -1,2 +1,2 @@ -export { {{pascalCase name}} } from './{{dashCase name}}' export type { {{pascalCase name}}Props } from './{{dashCase name}}' +export { {{pascalCase name}} } from './{{dashCase name}}' diff --git a/.prettierignore b/.prettierignore index 61fa2401..189c5fca 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,5 @@ dist/** coverage/** docs/** lib/** +# Prettier's MDX parser predates MDX 2/3 and corrupts {/* … */} comments (see PR #1091) +*.mdx diff --git a/AGENTS.md b/AGENTS.md index e2bae8ff..aaa98c8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,8 @@ src/ component-name.tsx # Implementation component-name.module.css # CSS Modules styles component-name.test.tsx # Tests - component-name.stories.mdx # Storybook docs (or .tsx) + component-name.stories.tsx # Storybook stories (CSF) + component-name.mdx # Storybook MDX docs page styles/ design-tokens.css # Global CSS custom properties (--reactist-*) utils/ @@ -51,7 +52,7 @@ src/ ### File structure -Every component has `index.ts`, `component.tsx`, `component.module.css`, `component.test.tsx`, and a story file. Use `npm run plop component` to scaffold new components. +Every component has `index.ts`, `component.tsx`, `component.module.css`, `component.test.tsx`, a `component.stories.tsx` story file, and a `component.mdx` docs page. Use `npm run plop component` to scaffold new components. The `component.mdx` docs page follows the section structure documented in README.md under "Documenting a component"; keep the heading names and order, uncomment the optional sections you fill in, wrap any markdown table in a `<Markdown>` block (no remark-gfm), and note that Prettier is never run on `.mdx` files. ### Implementation patterns diff --git a/README.md b/README.md index 26522078..214ac06c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,25 @@ The generated source files include the component implementation with sample prop You also need to export your new component by adding a reference to it in the [top-level index file](src/index.ts). +### Documenting a component + +The generator also creates a `<component-name>.mdx` documentation page next to the stories; its live examples render from the generated `Default`, `Variants`, `Accessibility`, and `Playground` stories. For an existing component that lacks a docs page, `npm run plop docs` scaffolds just the `.mdx` (repoint its canvases at the component's real stories as your first step). + +Every docs page follows the same structure, in this order: + +1. `<Title />` and `<Subtitle>` (rendered from the story meta, so the page title matches the sidebar) +2. **Overview**: what the component is and the problem it solves, linking the matching [design handbook page](https://github.com/Doist/component-libraries/tree/main/handbook/components) for anatomy and usage guidance +3. **Anatomy** (compound components only): a table mapping each exported part to what it renders and owns +4. **When to use**: concrete scenarios, including when to prefer another component +5. **Basic usage**: the simplest real example +6. **Variants** and **States**: visual variations; rename or replace these with domain sections when the component's modes need it (e.g. a Docked/Overlay split) +7. **Props**: an interactive playground canvas with the props table directly below it; compound components add a `### <Component.Part>` block with `Description` and `ArgTypes` per exported part, importing the parts at the top of the file +8. **Custom properties**: the component's CSS custom properties as a Property/Default/Purpose table (a `ColorPalette` is fine for long color lists) +9. **What the consumer owns** (optional): integration responsibilities the component deliberately leaves to the consumer +10. **Accessibility**: keyboard, screen reader, and focus behavior, with a demo canvas + +Keep the heading names and order. Optional sections are scaffolded as `{/* … */}` comments: uncomment the ones you fill in, delete the ones that don't apply. Constraints consumers must not violate (e.g. "only one modal overlay at a time") get a short callout inside the section they belong to. Markdown tables (Anatomy, Custom properties) must be wrapped in a `<Markdown>{` … `}</Markdown>` block with their inner backticks escaped, since this repo's MDX has no remark-gfm and renders bare tables as literal text (see `avatar.mdx`). Prettier does not format `.mdx` (its MDX parser predates MDX 2/3 and corrupts comments), so match the surrounding formatting by hand. + ## Storybook For the first development mode run: diff --git a/lint-staged.config.js b/lint-staged.config.js index 06ae75b2..4594fa56 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -1,5 +1,5 @@ module.exports = { - './**/*.{js,jsx,ts,tsx,md,mdx,json,css,scss,less}': ['prettier --write'], + './**/*.{js,jsx,ts,tsx,md,json,css,scss,less}': ['prettier --write'], './**/*.{js,jsx,ts,tsx}': ['npx eslint --format codeframe --fix'], 'src/**/*.{js,jsx,ts,tsx}': ['npx @doist/react-compiler-tracker --stage-record-file'], } diff --git a/package.json b/package.json index e9c72c12..bd145dba 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "type-check:react18": "tsc --noEmit -p ./tsconfig.react18.json", "lint": "eslint --format codeframe --cache --ext js,jsx,ts,tsx ./", "storybook": "storybook dev -p 6006", - "prettify": "prettier --write \"./**/*.{js,jsx,ts,tsx,json,css,scss,less,md,mdx}\"", + "prettify": "prettier --write \"./**/*.{js,jsx,ts,tsx,json,css,scss,less,md}\"", "plop": "plop" }, "peerDependencies": { diff --git a/plopfile.mjs b/plopfile.mjs index 14bfb621..78a10f15 100644 --- a/plopfile.mjs +++ b/plopfile.mjs @@ -23,6 +23,21 @@ function componentGenerator(plop) { message: 'Component name (e.g. "dropdown select", "sortable-table", "PromotionBanner")', }, + { + type: 'list', + name: 'category', + message: 'Storybook category (the section the component belongs to)', + choices: [ + '📐 Layout', + '🔤 Typography', + '🔘 Buttons & links', + '📝 Form', + '📑 Menus & tabs', + '📊 Data display', + '💬 Feedback', + '🪟 Overlays', + ], + }, ], actions() { @@ -56,12 +71,43 @@ function componentGenerator(plop) { templateFile: templateFile('component/component.stories.tsx'), }) + actions.push({ + type: 'add', + path: 'src/{{dashCase name}}/{{dashCase name}}.mdx', + templateFile: templateFile('component/component.mdx'), + }) + return actions }, }) } +/** @param {NodePlopAPI} plop */ +function docsGenerator(plop) { + plop.setGenerator('docs', { + description: 'MDX docs page for an existing component that lacks one', + + prompts: [ + { + type: 'input', + name: 'name', + message: + 'Existing component name, matching its directory (e.g. "toast", "text field")', + }, + ], + + actions: [ + { + type: 'add', + path: 'src/{{dashCase name}}/{{dashCase name}}.mdx', + templateFile: templateFile('component/component.mdx'), + }, + ], + }) +} + /** @param {NodePlopAPI} plop */ export default function (plop) { componentGenerator(plop) + docsGenerator(plop) }