|
| 1 | +--- |
| 2 | +name: promoting-composites-to-components |
| 3 | +description: >- |
| 4 | + Promote a storybook UI-pattern composite (examples/src/storybook/ui-patterns/components/composites/*.tsx) |
| 5 | + into a published @axiscommunications/fluent-* component package in this repo. USE WHEN the user wants to |
| 6 | + turn a demo/composite (e.g. Pagination) into a real publishable component, asks whether a component is |
| 7 | + "dev ready" / publishable, asks how to scaffold a new component package, or asks how to add unit tests, |
| 8 | + Playwright system tests, or accessibility testing the way the original repo does it. Covers the |
| 9 | + dev-readiness checklist, the package scaffolding recipe, vitest + Testing Library a11y patterns, the |
| 10 | + examples/system-test Playwright setup, and the repo's publishing/best-practice conventions. |
| 11 | +--- |
| 12 | + |
| 13 | +# Promoting a composite into a publishable Fluent component |
| 14 | + |
| 15 | +Goal: take a reference composite that currently lives only in the examples app |
| 16 | +(`examples/src/storybook/ui-patterns/components/composites/*.tsx`) and turn it into |
| 17 | +a real, releasable package under `components/<name>/`, matching the conventions of |
| 18 | +the existing published components (slider, stepper, topbar, side-navigation, |
| 19 | +password-input, empty-view). |
| 20 | + |
| 21 | +## Two tiers: UI patterns vs components |
| 22 | + |
| 23 | +This repo is intentionally split into two tiers. They are named differently on |
| 24 | +purpose — "patterns" are not yet "components". |
| 25 | + |
| 26 | +| Tier | Location | Owner | Status | |
| 27 | +|------|----------|-------|--------| |
| 28 | +| **UI patterns** (design-controlled staging) | `examples/src/storybook/ui-patterns/components/composites/*.tsx` | Design | Unpublished; the `examples` package is `"private": true`, so patterns iterate freely with no release/semver pressure. This is where the heavy lift (UX, visuals, tokens, a11y intent, variants) happens. | |
| 29 | +| **Components** (internal source lib) | `components/<name>/` | Dev contributors | Published `@axiscommunications/fluent-*`, versioned, contract-bound. Opened up for dev contribution once the pattern is settled. | |
| 30 | + |
| 31 | +Promotion (pattern → component) is the handoff gate between the two tiers, and the |
| 32 | +rest of this skill is that runbook. Two distinct sign-offs gate it: |
| 33 | + |
| 34 | +- **Design-ready** (design owns): behavior, visuals, tokens, accessibility intent, |
| 35 | + and variants are settled. The pattern can now be promoted. |
| 36 | +- **Dev-ready / publishable** (dev + maintainers own): the **public API is the |
| 37 | + contract** — prop shape, peer deps, tests, docs, and semver impact are reviewed. |
| 38 | + A pattern can look perfect yet still have an awkward API (see the Pagination |
| 39 | + prop-surface / string-interpolation notes below), so this is a separate decision, |
| 40 | + not just a visual sign-off. The repo is at major v12 — breaking a published API |
| 41 | + is costly, so lock the API here, not in the pattern tier. |
| 42 | + |
| 43 | +`advanced-data-grid` (`0.1.0`, `"private": true`) is the in-between case: promoted |
| 44 | +into `components/` structurally but still gated from publishing while it stabilizes. |
| 45 | + |
| 46 | +## 1. Decide if it should be a component at all |
| 47 | + |
| 48 | +Promote a composite only when it is reusable, self-contained, and design-system |
| 49 | +aligned. Good signals (use Pagination as the worked example): |
| 50 | + |
| 51 | +- It already uses `forwardRef`, typed props, token-driven `makeStyles`, and a11y |
| 52 | + labels — i.e. it is written like a component, not a one-off demo. |
| 53 | +- It depends only on Fluent peers (`@fluentui/react-components`, |
| 54 | + `@fluentui/react-icons`) — the same peer model every component package uses. |
| 55 | +- It fills a real gap (e.g. Pagination pairs with the published |
| 56 | + `usePageController` hook in `hooks/`, which ships the logic but no UI). |
| 57 | + |
| 58 | +Refine the API before publishing: trim oversized prop surfaces, prefer accepting a |
| 59 | +controller object over duplicating its outputs, and replace brittle string |
| 60 | +interpolation (`"X - Y of Z".replace(...)`) with formatter callbacks for i18n. |
| 61 | +Also resolve overlap with existing components (e.g. advanced-data-grid already has |
| 62 | +its own pagination — decide whether it should consume the shared component). |
| 63 | + |
| 64 | +## 2. Dev-readiness / publishable checklist |
| 65 | + |
| 66 | +A package is "dev ready" (installable by another developer) when ALL of: |
| 67 | + |
| 68 | +- [ ] `components/<name>/package.json` has **no** `"private": true` (the only |
| 69 | + private component today is `advanced-data-grid` at `0.1.0`). |
| 70 | +- [ ] `name` is `@axiscommunications/fluent-<name>`, `version` matches the shared |
| 71 | + monorepo version (all packages release together — currently `12.6.1`). |
| 72 | +- [ ] `exports` maps `.` → `./lib/index.d.ts` (types) + `./lib/index.js` (import), |
| 73 | + and `files` is `["lib"]`. |
| 74 | +- [ ] `peerDependencies` declare `@fluentui/react-components`, |
| 75 | + `@fluentui/react-icons`, `react`, `react-dom` (NOT direct deps). |
| 76 | +- [ ] `publishConfig.registry` is `https://npm.pkg.github.com/` (GitHub Packages, |
| 77 | + not public npm) — consumers need an `.npmrc` scope mapping + auth token. |
| 78 | +- [ ] Barrel `src/index.ts` exports the component(s) + all public types. |
| 79 | +- [ ] A `docs/README.md` exists (install + usage + props table + Accessibility). |
| 80 | +- [ ] At least one `*.spec.tsx` unit test (vitest + Testing Library). |
| 81 | +- [ ] `pnpm build`, `pnpm --filter @axiscommunications/fluent-<name> lint`, and |
| 82 | + `test` all pass. |
| 83 | + |
| 84 | +## 3. Scaffold the package |
| 85 | + |
| 86 | +Mirror an existing small package (side-navigation is a good template). Layout: |
| 87 | + |
| 88 | +``` |
| 89 | +components/<name>/ |
| 90 | +├── src/ |
| 91 | +│ ├── index.ts # barrel: components + types + style hooks |
| 92 | +│ ├── <name>.tsx # implementation (move from the composite) |
| 93 | +│ ├── <name>.types.ts # Props + item/sub-types |
| 94 | +│ ├── <name>.styles.ts # makeStyles + classNames map |
| 95 | +│ ├── <name>.spec.tsx # vitest unit + a11y tests |
| 96 | +│ └── setupTest.ts # imports "@testing-library/jest-dom" |
| 97 | +├── docs/README.md |
| 98 | +├── package.json # copy from side-navigation, rename, no "private" |
| 99 | +├── tsconfig.json # extends ../../tsconfig.base.json |
| 100 | +├── tsconfig.build.json # emits .d.ts into lib/ |
| 101 | +├── vitest.config.ts # jsdom, globals, setupFiles ./src/setupTest.ts |
| 102 | +└── depcheck.yml |
| 103 | +``` |
| 104 | + |
| 105 | +Per-package scripts (identical across packages): |
| 106 | + |
| 107 | +| Script | Runs | |
| 108 | +|--------|------| |
| 109 | +| `build` | `pnpm build:types && pnpm build:esm` | |
| 110 | +| `build:esm` | `esbuild --format=esm --bundle --sourcemap --packages=external --outdir=lib src/index.ts` | |
| 111 | +| `build:types` | `tsc -p tsconfig.build.json` | |
| 112 | +| `lint` | `tsc --noEmit && biome check` | |
| 113 | +| `test` | `vitest run` | |
| 114 | + |
| 115 | +Then add the package to the examples app as a `workspace:*` dependency and update |
| 116 | +the relevant story to import from the new package instead of the local composite. |
| 117 | + |
| 118 | +## 4. Unit tests + accessibility testing (vitest + Testing Library) |
| 119 | + |
| 120 | +Component-level tests live next to the source as `*.spec.tsx` and run on |
| 121 | +`vitest run` (jsdom, globals). Pattern — see |
| 122 | +`components/side-navigation/src/side-navigation.spec.tsx`: |
| 123 | + |
| 124 | +- Always render inside a Fluent provider: |
| 125 | + `render(<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>)`. |
| 126 | +- Assert by **role + accessible name**, not by class or test id: |
| 127 | + `getByRole("button", { name: "Home" })`. |
| 128 | +- Verify a11y semantics directly: selected item exposes |
| 129 | + `aria-current="page"`, groups expose `aria-expanded`, toggles expose an |
| 130 | + accessible label. Use `toHaveAttribute(...)` from `@testing-library/jest-dom`. |
| 131 | +- Cover controlled vs uncontrolled, disabled items, and keyboard/click |
| 132 | + interaction (`fireEvent.click`, `vi.fn()` for callbacks). |
| 133 | + |
| 134 | +Storybook also runs the **a11y addon** (`@storybook/addon-a11y`, configured in |
| 135 | +`examples/.storybook/main.ts`) — every story gets an automated axe audit in the |
| 136 | +Accessibility panel. When you add stories for the new component, check that panel |
| 137 | +is clean and document an `## Accessibility` section in the story and the README |
| 138 | +(landmark role, `aria-*`, reduced-motion handling), as the existing components do. |
| 139 | + |
| 140 | +## 5. Playwright system tests (examples/system-test) |
| 141 | + |
| 142 | +End-to-end/system tests drive the real examples app in a browser. They live in |
| 143 | +`examples/system-test/` and end in `.stest.ts`. Setup: |
| 144 | + |
| 145 | +- **Config:** `examples/playwright.config.ts` extends |
| 146 | + `examples/playwright.confg.base.ts`; project name |
| 147 | + `fluent-components:stest:chromium`, `testMatch: ["system-test/**.stest.ts"]`, |
| 148 | + viewport 1280×720, chromium `channel: process.env.BROWSER ?? "chrome"`. |
| 149 | +- **Run:** `cd examples && pnpm stest` (or `pnpm stest:ui`). Locally the suite |
| 150 | + targets the Vite dev server at `http://127.0.0.1:3000/fluent-components/`; CI |
| 151 | + (`CI=true`) targets the published GH Pages site. See |
| 152 | + `system-test/util/common.ts` (`getRootPath`). |
| 153 | +- **Dev server gotcha:** start it as |
| 154 | + `pnpm --filter examples exec vite --host 127.0.0.1` — plain `pnpm dev` binds |
| 155 | + only to `localhost`/IPv6 `::1` and refuses `127.0.0.1`. |
| 156 | +- **Browser gotcha:** branded Chrome needs sudo; instead |
| 157 | + `pnpm exec playwright install chromium` and run with `BROWSER=chromium`. |
| 158 | +- **Routing:** the app uses HashRouter — navigate to `${getRootPath()}#/<route>`. |
| 159 | + |
| 160 | +Authoring pattern (page-object model): |
| 161 | + |
| 162 | +1. Add a model in `system-test/models/<name>-page.model.ts` exposing locators |
| 163 | + (prefer `getByRole` / `getByLabel`; fall back to `getByTestId` for toolbar |
| 164 | + controls) and actions, plus a `goto()` that navigates and waits for the root |
| 165 | + to be visible. Example: `models/advanced-data-grid-page.model.ts`. |
| 166 | +2. Register it as a fixture in `system-test/util/test.ts` |
| 167 | + (`base.extend<TTestFixtures>({ ... })`). |
| 168 | +3. Write the spec as `system-test/<name>.stest.ts` using |
| 169 | + `import { test } from "./util/test"` and `expect` from `@playwright/test`; |
| 170 | + structure with `test.describe` + `test.beforeEach(({ fixture }) => fixture.goto())`. |
| 171 | + Example: `advanced-data-grid.stest.ts`. |
| 172 | + |
| 173 | +Scope locators to a stable root (e.g. `.axis-<Component>`) and use `.first()` when |
| 174 | +a story renders multiple instances of the component. |
| 175 | + |
| 176 | +## 6. Best practices from the repo |
| 177 | + |
| 178 | +- **Tooling:** Biome (not ESLint/Prettier) for lint+format; 2-space indent, |
| 179 | + 80-col width; no unused imports/vars (errors in Biome AND tsc strict). |
| 180 | +- **Conventional commits** (commitlint): `type(scope): message` — `feat`, `fix`, |
| 181 | + `docs`, `refactor`, `test`, `chore`, etc. Required for the release pipeline. |
| 182 | +- **Class names** are prefixed `axis-<ComponentName>` (e.g. `axis-Slider`), |
| 183 | + exposed via a `classNames` map in `*.styles.ts`. |
| 184 | +- **Separate files per concern:** `.tsx` logic, `.types.ts` types, `.styles.ts` |
| 185 | + styles; barrel `index.ts` re-exports components + types + style hooks. |
| 186 | +- **Styling:** `makeStyles` + `mergeClasses` + Fluent `tokens`; never raw hex. |
| 187 | +- **Peer deps, not direct deps** for `@fluentui/*` and `react`; internal packages |
| 188 | + via the pnpm `workspace:` protocol. |
| 189 | +- **CI** (`.github/workflows/verify.yml`) runs commit lint → format check → lint → |
| 190 | + build → test → unused-deps on every PR. Merging a release commit |
| 191 | + (`pnpm exec release major|minor|patch`) auto-publishes all packages. |
| 192 | +- Don't add a second divergent implementation of an existing capability — wire the |
| 193 | + new component into existing consumers (stories, data grid) instead. |
0 commit comments