From 98e2a29f40cd255e7044a72dd236fd05b1669ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Mon, 27 Jul 2026 18:04:37 +0200 Subject: [PATCH 1/3] feat(docs-app,ui-buttons): add auto-generated prop playground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an internal `PropEditor` docs component that reads a component's react-docgen metadata at runtime and generates a form of editable prop controls (selects for string-literal unions, a toggle for booleans, text and number inputs) with a live preview and the equivalent JSX. It lets non-devs experiment with components without editing code. The editor is registered as a docs global so it can be dropped into any README via a `type: embed` block, and accepts an optional `config` for per-component adjustments (include/exclude, control overrides, sample children). Wire it up as a proof of concept in the Button v2 README. Preview and code generation reuse the existing Preview/compileAndRenderExample pipeline; theme and version come from AppContext. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/__docs__/globals.ts | 5 +- packages/__docs__/src/PropEditor/index.tsx | 306 ++++++++++++++++++ .../__docs__/src/PropEditor/propControls.ts | 189 +++++++++++ packages/__docs__/src/PropEditor/props.ts | 99 ++++++ packages/ui-buttons/src/Button/v2/README.md | 9 + 5 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 packages/__docs__/src/PropEditor/index.tsx create mode 100644 packages/__docs__/src/PropEditor/propControls.ts create mode 100644 packages/__docs__/src/PropEditor/props.ts diff --git a/packages/__docs__/globals.ts b/packages/__docs__/globals.ts index 783c8c1ac8..fa07285eea 100644 --- a/packages/__docs__/globals.ts +++ b/packages/__docs__/globals.ts @@ -61,6 +61,8 @@ import placeholderImage from './buildScripts/samplemedia/placeholder-image' import ThemeColors from './src/ThemeColors' // eslint-disable-next-line no-restricted-imports import ColorTable from './src/ColorTable' +// eslint-disable-next-line no-restricted-imports +import { PropEditor } from './src/PropEditor' import { additionalPrimitives, dataVisualization } from '@instructure/ui-themes' @@ -107,7 +109,8 @@ const globals: Record = { additionalPrimitives, dataVisualization, ThemeColors, - ColorTable + ColorTable, + PropEditor } Object.keys(globals).forEach((key) => { diff --git a/packages/__docs__/src/PropEditor/index.tsx b/packages/__docs__/src/PropEditor/index.tsx new file mode 100644 index 0000000000..8301a0aab1 --- /dev/null +++ b/packages/__docs__/src/PropEditor/index.tsx @@ -0,0 +1,306 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { useContext, useEffect, useMemo, useState } from 'react' + +import { View } from '@instructure/ui-view' +import { Flex } from '@instructure/ui-flex' +import { Text } from '@instructure/ui-text' +import { Spinner } from '@instructure/ui-spinner' +import { Checkbox } from '@instructure/ui-checkbox' +import { TextInput } from '@instructure/ui-text-input' +import { NumberInput } from '@instructure/ui-number-input' +import { SimpleSelect } from '@instructure/ui-simple-select' +import { SourceCodeEditor } from '@instructure/ui-source-code-editor' + +import { AppContext } from '../appContext' +import Preview from '../Preview' +import { getDeployBase } from '../navigationUtils' + +import { generateControls, serializeJsx } from './propControls' +import type { + Control, + PropEditorProps, + PropValue, + ReactDocgenProps +} from './props' + +type Status = 'loading' | 'ready' | 'error' + +const noop = () => {} + +/** + * An auto-generated, form-based playground for a component's props. Reads the + * component's react-docgen metadata (fetched at runtime, the same JSON the + * props table uses), derives a form control per prop, and renders a live + * preview plus the equivalent JSX. Intended for use inside component READMEs + * via a `type: embed` code block, e.g. ``. + * + * @private used only by the docs app. + */ +function PropEditor({ componentId, config = {} }: PropEditorProps) { + const { componentVersion, themeKey, themes } = useContext(AppContext) + const name = componentId + + const [docgenProps, setDocgenProps] = useState(null) + const [status, setStatus] = useState('loading') + const [errorMsg, setErrorMsg] = useState('') + const [values, setValues] = useState>({}) + + // A theme override local to the preview, so a reader can flip themes without + // scrolling back to the page-level theme switcher. Seeded from the app's + // selected theme and reset to it whenever that changes. + const [selectedTheme, setSelectedTheme] = useState(String(themeKey)) + useEffect(() => { + setSelectedTheme(String(themeKey)) + }, [themeKey]) + + // The switchable themes, minus the shared-tokens bundle and the legacy + // wrappers (v2 components use the new theming system). + const themeOptions = useMemo( + () => + Object.keys(themes || {}).filter( + (key) => key !== 'shared-tokens' && !key.startsWith('legacy-') + ), + [themes] + ) + + // Fetch the component's prop metadata (mirrors App.getDocsBasePath). + useEffect(() => { + let cancelled = false + const base = getDeployBase() + const versionSeg = componentVersion ? `/${componentVersion}` : '' + const url = `${base}/docs${versionSeg}/${name}.json` + + setStatus('loading') + fetch(url) + .then((res) => { + if (!res.ok) { + throw new Error(`request failed (${res.status})`) + } + return res.json() + }) + .then((data) => { + if (cancelled) return + setDocgenProps((data.props as ReactDocgenProps) || {}) + setStatus('ready') + }) + .catch((err) => { + if (cancelled) return + setErrorMsg(err?.message || String(err)) + setStatus('error') + }) + + return () => { + cancelled = true + } + }, [name, componentVersion]) + + const { controls, skipped } = useMemo( + () => + docgenProps + ? generateControls(docgenProps, config) + : { controls: [] as Control[], skipped: [] as string[] }, + [docgenProps, config] + ) + + // Seed the form with each control's default whenever the controls change. + useEffect(() => { + const initial: Record = {} + controls.forEach((control) => { + initial[control.name] = control.initialValue + }) + setValues(initial) + }, [controls]) + + const code = useMemo( + () => serializeJsx(name, controls, values), + [name, controls, values] + ) + + const setValue = (propName: string, value: PropValue) => { + setValues((prev) => ({ ...prev, [propName]: value })) + } + + const renderControl = (control: Control) => { + const value = values[control.name] + + if (control.type === 'boolean') { + return ( + setValue(control.name, event.target.checked)} + /> + ) + } + + if (control.type === 'select') { + return ( + + setValue(control.name, selected === '' ? undefined : selected) + } + > + {!control.required && ( + + (unset) + + )} + {(control.options || []).map((option) => ( + + {option} + + ))} + + ) + } + + if (control.type === 'number') { + return ( + + setValue(control.name, val === '' ? undefined : Number(val)) + } + /> + ) + } + + return ( + setValue(control.name, val)} + /> + ) + } + + if (status === 'loading') { + return ( + + + + Loading {name} props… + + + ) + } + + if (status === 'error') { + return ( + + + Could not load props for {name}: {errorMsg} + + + ) + } + + return ( + + + {/* Controls column: narrow, holds the theme + prop selectors. */} + + {themeOptions.length > 1 && ( + + + setSelectedTheme(String(selected)) + } + > + {themeOptions.map((option) => ( + + {option} + + ))} + + + )} + Props + + {controls.map((control) => ( + + {renderControl(control)} + + ))} + + {skipped.length > 0 && ( + + + Not editable here: {skipped.join(', ')} + + + )} + + + {/* Preview column: takes the remaining space beside the controls. */} + + Preview + + + + + + + + + + + ) +} + +PropEditor.displayName = 'PropEditor' + +export default PropEditor +export { PropEditor } diff --git a/packages/__docs__/src/PropEditor/propControls.ts b/packages/__docs__/src/PropEditor/propControls.ts new file mode 100644 index 0000000000..3faeebe401 --- /dev/null +++ b/packages/__docs__/src/PropEditor/propControls.ts @@ -0,0 +1,189 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { + Control, + ControlType, + PropEditorConfig, + PropValue, + ReactDocgenProp, + ReactDocgenProps +} from './props' + +/** + * Props react-docgen surfaces that are internal plumbing rather than public + * API β€” the same ones the docs props table hides (see `src/Properties`). + */ +const INTERNAL_PROPS = ['styles', 'makeStyles', 'dir', 'elementRef'] + +/** Strips a single layer of surrounding single/double quotes. */ +function stripQuotes(raw: string): string { + return raw.replace(/^['"]|['"]$/g, '') +} + +/** + * Turns a react-docgen `defaultValue.value` (a raw source string like + * `"'secondary'"`, `"true"`, `"42"`) into a concrete form value. + */ +function parseDefault(raw: string | undefined, type: ControlType): PropValue { + if (raw === undefined || raw === 'undefined') return undefined + if (type === 'boolean') return raw === 'true' + if (type === 'number') { + const n = Number(raw) + return Number.isNaN(n) ? undefined : n + } + return stripQuotes(raw) +} + +/** + * A union whose every element is a string literal maps cleanly to a select. + */ +function unionOptions(prop: ReactDocgenProp): string[] | null { + const els = prop.tsType?.elements + if (!els || els.length === 0) return null + const allLiterals = els.every( + (el) => el.name === 'literal' && typeof el.value === 'string' + ) + if (!allLiterals) return null + return els.map((el) => stripQuotes(el.value as string)) +} + +/** + * Picks a control type from a prop's TS type. Returns `null` for anything the + * auto-generated form can't sensibly edit (functions, objects, complex unions). + */ +function inferControlType(prop: ReactDocgenProp): ControlType | null { + const tsName = prop.tsType?.name + if (tsName === 'boolean') return 'boolean' + if (tsName === 'number') return 'number' + if (tsName === 'union' && unionOptions(prop)) return 'select' + if (tsName === 'string' || tsName === 'ReactReactNode') return 'text' + return null +} + +/** + * Builds the list of form controls from react-docgen prop metadata, applying + * any author-supplied config. Props that can't be auto-edited are returned in + * `skipped` so the UI can disclose them rather than silently dropping them. + */ +export function generateControls( + docgenProps: ReactDocgenProps, + config: PropEditorConfig = {} +): { controls: Control[]; skipped: string[] } { + const { include, exclude = [], sampleChildren, overrides = {} } = config + + const names = Object.keys(docgenProps).filter((name) => { + if (INTERNAL_PROPS.includes(name)) return false + if (include && !include.includes(name)) return false + if (exclude.includes(name)) return false + return true + }) + + const controls: Control[] = [] + const skipped: string[] = [] + + for (const name of names) { + const prop = docgenProps[name] + const override = overrides[name] || {} + + const type = override.control ?? inferControlType(prop) + if (!type) { + skipped.push(name) + continue + } + + const options = + type === 'select' + ? override.options ?? unionOptions(prop) ?? [] + : undefined + + let initialValue = + override.default !== undefined + ? override.default + : parseDefault(prop.defaultValue?.value, type) + + // `children` has no meaningful default β€” seed it so the preview isn't empty. + if (name === 'children' && initialValue === undefined) { + initialValue = sampleChildren + } + + controls.push({ + name, + type, + options, + required: Boolean(prop.required), + description: prop.description, + initialValue + }) + } + + return { controls, skipped } +} + +/** + * Escapes a value for use inside a double-quoted JSX attribute. + */ +function attrString(value: string): string { + return value.replace(/"/g, '"') +} + +/** + * Serializes the current form values into a JSX snippet for ``. + * Props left at their default are omitted (so the snippet stays minimal and + * the preview relies on the component's own defaults β€” which are identical). + */ +export function serializeJsx( + displayName: string, + controls: Control[], + values: Record +): string { + const attrs: string[] = [] + let childrenText = '' + + for (const control of controls) { + const value = values[control.name] + + if (control.name === 'children') { + childrenText = value == null ? '' : String(value) + continue + } + + if (value === undefined || value === '') continue + if (value === control.initialValue) continue + + if (control.type === 'boolean') { + attrs.push(value === true ? control.name : `${control.name}={false}`) + } else if (control.type === 'number') { + attrs.push(`${control.name}={${value}}`) + } else { + attrs.push(`${control.name}="${attrString(String(value))}"`) + } + } + + const attrStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : '' + + return childrenText + ? `<${displayName}${attrStr}>${childrenText}` + : `<${displayName}${attrStr} />` +} diff --git a/packages/__docs__/src/PropEditor/props.ts b/packages/__docs__/src/PropEditor/props.ts new file mode 100644 index 0000000000..8dedaad06b --- /dev/null +++ b/packages/__docs__/src/PropEditor/props.ts @@ -0,0 +1,99 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * A single value the editor tracks for a prop. Complex values (functions, + * elements) are out of scope for the auto-generated form. + */ +export type PropValue = string | number | boolean | undefined + +export type ControlType = 'select' | 'boolean' | 'text' | 'number' + +/** + * The subset of a react-docgen prop descriptor the editor consumes. Mirrors + * the JSON shape emitted to `__build__/docs//.json`. + */ +export type ReactDocgenTsType = { + name: string + raw?: string + elements?: Array<{ name: string; value?: string }> +} + +export type ReactDocgenProp = { + required?: boolean + tsType?: ReactDocgenTsType + description?: string + defaultValue?: { value: string; computed: boolean } +} + +export type ReactDocgenProps = Record + +/** + * A resolved control ready to render as a form field. + */ +export type Control = { + name: string + type: ControlType + /** Select options (bare string values, quotes stripped). */ + options?: string[] + required: boolean + description?: string + /** The component's default for this prop; also the initial form value. */ + initialValue: PropValue +} + +/** + * Per-prop override, letting a doc author tune the auto-generated form. + */ +export type PropOverride = { + control?: ControlType + options?: string[] + default?: PropValue +} + +/** + * Optional configuration passed from a README to adjust the generated form. + * Everything is optional β€” with no config the form is derived entirely from + * the component's prop metadata. + */ +export type PropEditorConfig = { + /** If set, only these props get controls. */ + include?: string[] + /** These props never get controls. */ + exclude?: string[] + /** Default text used for the `children` control. */ + sampleChildren?: string + /** Per-prop control overrides, keyed by prop name. */ + overrides?: Record +} + +export type PropEditorProps = { + /** + * The component's name. Used both to fetch its prop metadata (the doc JSON + * file name) and as the tag rendered in the preview, so it must match a + * component registered in the docs globals (e.g. `"Button"`, `"Avatar"`). + */ + componentId: string + config?: PropEditorConfig +} diff --git a/packages/ui-buttons/src/Button/v2/README.md b/packages/ui-buttons/src/Button/v2/README.md index e6bff47106..bb4fbaf381 100644 --- a/packages/ui-buttons/src/Button/v2/README.md +++ b/packages/ui-buttons/src/Button/v2/README.md @@ -298,6 +298,15 @@ type: example render() ``` +### Playground + +```js +--- +type: embed +--- + +``` + ### Guidelines ```js From cb618acb1e760e6a00dfc4bd95f6897aff507335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Wed, 29 Jul 2026 09:13:13 +0200 Subject: [PATCH 2/3] chore: add comprehension-check gate before /commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a /comprehension-check skill that quizzes the author on the current diff's design decisions before committing, to stop AI-generated code the author can't explain from landing. It skips trivial diffs, asks free-text design questions, grades against the diff, and teaches then re-quizzes on gaps. Wire it into /commit as a step-0 preamble and whitelist the new skill file in .gitignore so it ships alongside the other shared commands. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/commands/commit.md | 1 + .claude/commands/comprehension-check.md | 68 +++++++++++++++++++++++++ .gitignore | 1 + 3 files changed, 70 insertions(+) create mode 100644 .claude/commands/comprehension-check.md diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index 8254fcd9d2..4aef5ca5ea 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -26,6 +26,7 @@ Co-Authored-By: Claude ## Steps +0. **Comprehension gate.** Unless the diff is trivial (pure docs/config/formatting/version bump/generated files), run the `/comprehension-check` skill first and only continue once the author has demonstrated understanding. Skip silently for trivial diffs. 1. `git status` + `git diff` (and `git diff --staged` if anything's staged), and **check the current branch is the right place for this commit**: - **Never commit on `master`/`main`.** If you're on it, stop and **offer to create a feature branch** from the current HEAD β€” `git switch -c /` carries the uncommitted changes onto the new branch β€” then commit there. Don't proceed on `master` even if the user didn't mention branching; confirm first. - If you're on a feature branch, glance at its name. If it looks **unrelated** to the change you're about to commit, flag it and offer to branch off (so you don't pile an unrelated commit onto someone else's WIP); otherwise proceed. diff --git a/.claude/commands/comprehension-check.md b/.claude/commands/comprehension-check.md new file mode 100644 index 0000000000..4ba6c7f431 --- /dev/null +++ b/.claude/commands/comprehension-check.md @@ -0,0 +1,68 @@ +--- +description: Quiz the author on the current diff's design before committing β€” a comprehension gate to prevent shipping code nobody understands +--- + +Gate a commit on the author demonstrating they understand the change. The point is to stop +"ok-looking" AI-generated code β€” and creeping architectural complexity β€” from landing when +the author can't actually explain it. This is a teaching gate, not a rubber stamp. + +## 1. Read the diff + +`git diff` and `git diff --staged`. Understand what actually changed. + +## 2. Decide if a quiz is warranted + +**Skip (say so and proceed straight to committing)** when the diff is only: Markdown/docs, +config, formatting/lint-only churn, version bumps, lockfiles, or generated files. + +**Quiz** when the diff introduces or changes real logic: new abstractions/components, +control flow, data flow, state, public API/props, cross-package wiring, or anything with a +non-obvious "why". Scale the number of questions to the change: ~2 for a small focused diff, +up to ~5 for a large or architectural one. + +Before writing questions, pick the 2-3 spots with the most hidden complexity or highest +"AI-slop risk" β€” clever one-liners, non-obvious control flow, React effect/memo dependency +arrays and referential stability, error/edge handling β€” and make at least one question target +each. Don't spend the quiz on the parts that were easy to write. + +## 3. Ask β€” one or two questions at a time, in plain chat + +Target the **decisions embedded in the code**, never syntax or trivia. Good angles: + +- **Why this way?** β€” why this approach over the obvious alternative; what trade-off was made. +- **Blast radius** β€” what else depends on this; what breaks if it changes; who calls it. +- **Data/control flow** β€” where does this get invoked from, and what happens next. +- **Edge cases** β€” what inputs/states does it handle (or deliberately not), and why. +- **Integration** β€” how it fits the monorepo: cross-package deps, theme/tokens, i18n, v1/v2. + +Rules: + +- Ask questions **you (Claude) already know the answer to from the diff** β€” so you can grade. +- Before asking, note to yourself the specific points a complete answer must hit β€” the answer + key straight from the diff. Grade the reply against that key, not against how confident or + fluent it sounds. +- **Never reveal the answer in the question**, and don't hand the author hints before they try. +- Ask in the author's own words territory β€” "explain…", "why…", "what would happen if…". + +## 4. Grade honestly + +The gate is worthless if you rubber-stamp. Hold a real bar, but grade the _understanding_, +not the phrasing: + +- **All parts, not most.** If a question has multiple parts, it passes only when _every_ part + is answered. Right on one part and silent/vague on another is **partial** β†’ probe the missing + part before moving on. Don't average. +- **Correct & shows understanding** β†’ acknowledge briefly, move on. +- **Coherent alternative that's actually right** (author knows something you didn't, or framed + it differently) β†’ accept it. The goal is demonstrated understanding, not matching your words. +- **Vague / partial** β†’ probe once more on the gap. +- **Wrong, or "I don't know"** β†’ **teach**: explain the decision clearly with `file:line` + references, then ask a _fresh_ question on the same concept. Loop until the author can + explain it back in their own words. Don't move on until they can. + +## 5. Pass + +When the author has demonstrated understanding of every key decision, give a one-line summary +of what they showed they understand, then proceed to commit (follow `/commit`). If the author +wrote the code themselves and breezes through, that's a pass β€” the gate is aimed at code they +haven't internalized, not at making them jump through hoops. diff --git a/.gitignore b/.gitignore index 3c9707498f..30eb480380 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ CLAUDE.local.md !.claude/commands/slack-setup.md !.claude/commands/implement.md !.claude/commands/ticket.md +!.claude/commands/comprehension-check.md # Playwright MCP .playwright-mcp From 4090706e16d18845fb471d47c7b8e9aba552d698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Thu, 30 Jul 2026 11:40:42 +0200 Subject: [PATCH 3/3] feat(docs-app,ui-buttons): extend prop playground to all components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-inject the PropEditor playground on every component doc page from `Document`, using the react-docgen metadata it already has (no runtime fetch). Simple standalone components get a single-element form; compound components get a curated composition playground that edits the parent and a representative child together, driven by a registry. - Generalize PropEditor to N sections plus a JSX template with `{{id}}` attribute placeholders; simple mode is one section with the default serializer. - Add a registry of custom composition playgrounds (Menu, Tabs, Table, Modal, Select, Flex, Grid, ... 17 total) plus a force-simple set for prop-driven components whose subcomponents are internal (Rating, Pagination, Calendar). - Gate: real compound components (dotted subcomponent ids) fall back to their README examples unless a custom entry exists; components whose only children are internal facades (Checkbox, FormField) still get the simple form. - Stabilize config identity and key the seed effect on a structural signature so composition edits are not wiped on every render. - Drop the now-redundant manual PropEditor embed from the Button v2 README. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/__docs__/src/Document/index.tsx | 89 ++++ packages/__docs__/src/PropEditor/index.tsx | 193 ++++++-- .../__docs__/src/PropEditor/propControls.ts | 65 ++- packages/__docs__/src/PropEditor/props.ts | 39 ++ packages/__docs__/src/PropEditor/registry.ts | 457 ++++++++++++++++++ packages/ui-buttons/src/Button/v2/README.md | 9 - 6 files changed, 786 insertions(+), 66 deletions(-) create mode 100644 packages/__docs__/src/PropEditor/registry.ts diff --git a/packages/__docs__/src/Document/index.tsx b/packages/__docs__/src/Document/index.tsx index c2e3161d05..406815f0c9 100644 --- a/packages/__docs__/src/Document/index.tsx +++ b/packages/__docs__/src/Document/index.tsx @@ -42,6 +42,14 @@ import { Returns } from '../Returns' import { ComponentTheme } from '../ComponentTheme' import { TableOfContents } from '../TableOfContents' import { Heading } from '../Heading' +import { PropEditor } from '../PropEditor' +import type { PropEditorSection, ReactDocgenProps } from '../PropEditor/props' +import { + shouldAutoInject, + getAutoInjectConfig, + getCustomPlayground, + isForceSimple +} from '../PropEditor/registry' import { AppContext } from '../appContext' import { navigateTo } from '../navigationUtils' @@ -154,6 +162,84 @@ class Document extends Component { ) : null } + renderPlayground(doc: DocDataType) { + const editor = this.getPlaygroundEditor(doc) + if (!editor) return null + + return ( + + + Playground + + {editor} + + ) + } + + // Which playground (if any) a component gets: + // - a curated composition, when a custom playground is registered; + // - the auto single-element form, for simple standalone components; + // - none, for compound components without a custom entry (they lean on + // their README examples) and anything the gate rejects. + getPlaygroundEditor(doc: DocDataType) { + const custom = getCustomPlayground(doc.id) + + if (custom) { + const sections = custom.sections + .map((section) => { + const sectionProps = + section.id === doc.id + ? doc.props + : doc.children?.find((child) => child.id === section.id)?.props + return sectionProps + ? { + id: section.id, + label: section.label, + props: sectionProps as ReactDocgenProps, + config: section.config + } + : null + }) + .filter((section): section is PropEditorSection => section !== null) + + // Bail if any section's metadata is missing rather than render a partial, + // broken composition. + if (sections.length !== custom.sections.length) return null + + return ( + + ) + } + + // Real compound components (whose children are composable subcomponents + // with dotted ids like `Menu.Item`) fall back to examples when they have no + // custom playground. Components whose only "children" are internal facades + // (non-dotted ids, e.g. Checkbox's `CheckboxFacade`) are not composed by + // consumers, so they still get the simple single-element form. + const hasSubcomponents = doc.children?.some((child) => + child.id?.includes('.') + ) + if (hasSubcomponents && !isForceSimple(doc.id)) return null + if (!shouldAutoInject(doc)) return null + + return ( + + ) + } + renderTheme(doc: DocDataType) { const { themeVariables } = this.props const { componentTheme } = this.state @@ -467,6 +553,9 @@ import { ${importName} } from '${versionedPackageName}'` {pageRef && } {['.js', '.ts', '.tsx'].includes(doc.extension) && this.renderUsage()} {this.renderDescription(doc, this.props.description)} + {/* Playground sits above the per-subcomponent details tabs so it always + applies to the whole component, not the selected tab. */} + {this.renderPlayground(doc)} {details} {this.renderEditOnGithub()} {repository && layout !== 'small' && ( diff --git a/packages/__docs__/src/PropEditor/index.tsx b/packages/__docs__/src/PropEditor/index.tsx index 8301a0aab1..cdc0d8d17a 100644 --- a/packages/__docs__/src/PropEditor/index.tsx +++ b/packages/__docs__/src/PropEditor/index.tsx @@ -38,7 +38,11 @@ import { AppContext } from '../appContext' import Preview from '../Preview' import { getDeployBase } from '../navigationUtils' -import { generateControls, serializeJsx } from './propControls' +import { + generateControls, + serializeComposition, + serializeJsx +} from './propControls' import type { Control, PropEditorProps, @@ -46,6 +50,17 @@ import type { ReactDocgenProps } from './props' +/** A section's resolved controls, ready to render and serialize. */ +type ResolvedSection = { + id: string + label: string + controls: Control[] + skipped: string[] +} + +/** Per-section form values: `{ [sectionId]: { [propName]: value } }`. */ +type SectionValues = Record> + type Status = 'loading' | 'ready' | 'error' const noop = () => {} @@ -59,14 +74,33 @@ const noop = () => {} * * @private used only by the docs app. */ -function PropEditor({ componentId, config = {} }: PropEditorProps) { +function PropEditor({ + componentId, + props: providedProps, + config: configProp, + sections: sectionInputs, + template +}: PropEditorProps) { const { componentVersion, themeKey, themes } = useContext(AppContext) const name = componentId - const [docgenProps, setDocgenProps] = useState(null) - const [status, setStatus] = useState('loading') + // Stabilize config: when it isn't passed (composition mode), the inline + // default would be a fresh object every render, thrashing the memo below and + // re-seeding (i.e. wiping) the form on every keystroke. + const config = useMemo(() => configProp ?? {}, [configProp]) + + // Composition mode: multiple elements edited against a template. Otherwise + // the single-element form, sourcing metadata from props or a runtime fetch. + const isComposition = Boolean(sectionInputs && template) + + const [docgenProps, setDocgenProps] = useState( + providedProps ?? null + ) + const [status, setStatus] = useState( + providedProps || isComposition ? 'ready' : 'loading' + ) const [errorMsg, setErrorMsg] = useState('') - const [values, setValues] = useState>({}) + const [values, setValues] = useState({}) // A theme override local to the preview, so a reader can flip themes without // scrolling back to the page-level theme switcher. Seeded from the app's @@ -86,8 +120,17 @@ function PropEditor({ componentId, config = {} }: PropEditorProps) { [themes] ) - // Fetch the component's prop metadata (mirrors App.getDocsBasePath). + // Fetch the component's prop metadata (mirrors App.getDocsBasePath). Skipped + // when metadata is supplied via props (the auto-injected Document case) or in + // composition mode (each section carries its own metadata). useEffect(() => { + if (isComposition) return + if (providedProps) { + setDocgenProps(providedProps) + setStatus('ready') + return + } + let cancelled = false const base = getDeployBase() const versionSeg = componentVersion ? `/${componentVersion}` : '' @@ -115,36 +158,75 @@ function PropEditor({ componentId, config = {} }: PropEditorProps) { return () => { cancelled = true } - }, [name, componentVersion]) + }, [name, componentVersion, providedProps, isComposition]) - const { controls, skipped } = useMemo( + // Resolve every section's controls. Simple mode is just a single section + // keyed by the component name; composition mode has one per template slot. + const sections = useMemo(() => { + if (isComposition) { + return sectionInputs!.map((section) => { + const { controls, skipped } = generateControls( + section.props, + section.config || {} + ) + return { + id: section.id, + label: section.label || section.id, + controls, + skipped + } + }) + } + if (!docgenProps) return [] + const { controls, skipped } = generateControls(docgenProps, config) + return [{ id: name, label: name, controls, skipped }] + }, [isComposition, sectionInputs, docgenProps, config, name]) + + // A structural signature of the sections/controls. Seeding keys off this + // rather than the `sections` array identity, so an unrelated re-render that + // produces an equivalent `sections` won't re-seed (and wipe) the form. + const sectionsKey = useMemo( () => - docgenProps - ? generateControls(docgenProps, config) - : { controls: [] as Control[], skipped: [] as string[] }, - [docgenProps, config] + sections + .map((s) => `${s.id}:${s.controls.map((c) => c.name).join(',')}`) + .join('|'), + [sections] ) // Seed the form with each control's default whenever the controls change. useEffect(() => { - const initial: Record = {} - controls.forEach((control) => { - initial[control.name] = control.initialValue + const initial: SectionValues = {} + sections.forEach((section) => { + const sectionValues: Record = {} + section.controls.forEach((control) => { + sectionValues[control.name] = control.initialValue + }) + initial[section.id] = sectionValues }) setValues(initial) - }, [controls]) + // Seeding is keyed on the structural signature; `sections` itself is + // intentionally not a dependency (its identity changes on every render). + }, [sectionsKey]) - const code = useMemo( - () => serializeJsx(name, controls, values), - [name, controls, values] - ) + const code = useMemo(() => { + if (isComposition) { + return serializeComposition(template!, sections, values) + } + const section = sections[0] + return section + ? serializeJsx(name, section.controls, values[section.id] || {}) + : '' + }, [isComposition, template, sections, values, name]) - const setValue = (propName: string, value: PropValue) => { - setValues((prev) => ({ ...prev, [propName]: value })) + const setValue = (sectionId: string, propName: string, value: PropValue) => { + setValues((prev) => ({ + ...prev, + [sectionId]: { ...prev[sectionId], [propName]: value } + })) } - const renderControl = (control: Control) => { - const value = values[control.name] + const renderControl = (sectionId: string, control: Control) => { + const value = values[sectionId]?.[control.name] if (control.type === 'boolean') { return ( @@ -153,7 +235,9 @@ function PropEditor({ componentId, config = {} }: PropEditorProps) { size="small" label={control.name} checked={value === true} - onChange={(event) => setValue(control.name, event.target.checked)} + onChange={(event) => + setValue(sectionId, control.name, event.target.checked) + } /> ) } @@ -164,18 +248,25 @@ function PropEditor({ componentId, config = {} }: PropEditorProps) { renderLabel={control.name} value={value == null ? '' : String(value)} onChange={(_event, { value: selected }) => - setValue(control.name, selected === '' ? undefined : selected) + setValue( + sectionId, + control.name, + selected === '' ? undefined : selected + ) } > {!control.required && ( - + (unset) )} {(control.options || []).map((option) => ( {option} @@ -191,7 +282,11 @@ function PropEditor({ componentId, config = {} }: PropEditorProps) { renderLabel={control.name} value={value == null ? '' : String(value)} onChange={(_event, val) => - setValue(control.name, val === '' ? undefined : Number(val)) + setValue( + sectionId, + control.name, + val === '' ? undefined : Number(val) + ) } /> ) @@ -201,7 +296,7 @@ function PropEditor({ componentId, config = {} }: PropEditorProps) { setValue(control.name, val)} + onChange={(_event, val) => setValue(sectionId, control.name, val)} /> ) } @@ -260,21 +355,33 @@ function PropEditor({ componentId, config = {} }: PropEditorProps) { )} - Props - - {controls.map((control) => ( - - {renderControl(control)} - - ))} - - {skipped.length > 0 && ( - - - Not editable here: {skipped.join(', ')} + {sections.map((section, index) => ( + + {/* Single-section (simple) mode keeps the generic "Props" label; + composition mode labels each group by its element. */} + + {sections.length > 1 ? section.label : 'Props'} + + {section.controls.map((control) => ( + + {renderControl(section.id, control)} + + ))} + + {section.skipped.length > 0 && ( + + + Not editable here: {section.skipped.join(', ')} + + + )} - )} + ))} {/* Preview column: takes the remaining space beside the controls. */} diff --git a/packages/__docs__/src/PropEditor/propControls.ts b/packages/__docs__/src/PropEditor/propControls.ts index 3faeebe401..565688fbc3 100644 --- a/packages/__docs__/src/PropEditor/propControls.ts +++ b/packages/__docs__/src/PropEditor/propControls.ts @@ -47,7 +47,8 @@ function stripQuotes(raw: string): string { * `"'secondary'"`, `"true"`, `"42"`) into a concrete form value. */ function parseDefault(raw: string | undefined, type: ControlType): PropValue { - if (raw === undefined || raw === 'undefined') return undefined + if (raw === undefined || raw === 'undefined' || raw === 'null') + return undefined if (type === 'boolean') return raw === 'true' if (type === 'number') { const n = Number(raw) @@ -149,26 +150,21 @@ function attrString(value: string): string { } /** - * Serializes the current form values into a JSX snippet for ``. - * Props left at their default are omitted (so the snippet stays minimal and - * the preview relies on the component's own defaults β€” which are identical). + * Serializes the non-default values into a JSX attribute string (no leading or + * trailing space, `children` excluded), e.g. `placement="bottom" disabled`. + * Props left at their default are omitted so the snippet stays minimal and the + * preview relies on the component's own defaults β€” which are identical. */ -export function serializeJsx( - displayName: string, +export function serializeAttrs( controls: Control[], values: Record ): string { const attrs: string[] = [] - let childrenText = '' for (const control of controls) { - const value = values[control.name] - - if (control.name === 'children') { - childrenText = value == null ? '' : String(value) - continue - } + if (control.name === 'children') continue + const value = values[control.name] if (value === undefined || value === '') continue if (value === control.initialValue) continue @@ -181,9 +177,50 @@ export function serializeJsx( } } - const attrStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : '' + return attrs.join(' ') +} + +/** + * Serializes the current form values into a JSX snippet for ``. + */ +export function serializeJsx( + displayName: string, + controls: Control[], + values: Record +): string { + const attrs = serializeAttrs(controls, values) + const attrStr = attrs ? ` ${attrs}` : '' + + const childrenValue = controls.some((c) => c.name === 'children') + ? values.children + : undefined + const childrenText = childrenValue == null ? '' : String(childrenValue) return childrenText ? `<${displayName}${attrStr}>${childrenText}` : `<${displayName}${attrStr} />` } + +/** + * Fills a composition template's `{{sectionId}}` placeholders with each + * section's live attributes. A placeholder with no attributes collapses to + * nothing, and the trailing-space cleanup keeps `` tidy as + * `` when unset. `children` is authored statically in the template, so + * only attributes are injected here. + */ +export function serializeComposition( + template: string, + sections: Array<{ id: string; controls: Control[] }>, + values: Record> +): string { + let out = template + + for (const section of sections) { + const attrs = serializeAttrs(section.controls, values[section.id] || {}) + out = out.split(`{{${section.id}}}`).join(attrs) + } + + // Collapse the space left before a `>` when a placeholder expanded to empty + // (e.g. `` β†’ ``); self-closing ` />` is untouched. + return out.replace(/ >/g, '>') +} diff --git a/packages/__docs__/src/PropEditor/props.ts b/packages/__docs__/src/PropEditor/props.ts index 8dedaad06b..0ed699a257 100644 --- a/packages/__docs__/src/PropEditor/props.ts +++ b/packages/__docs__/src/PropEditor/props.ts @@ -88,6 +88,25 @@ export type PropEditorConfig = { overrides?: Record } +/** + * One editable element in a composition playground. Each section becomes a + * labeled group of controls and contributes its serialized attributes to the + * matching `{{id}}` placeholder in the template. + */ +export type PropEditorSection = { + /** + * The element id, e.g. `"Menu"` or `"Menu.Item"`. Must match both the + * `{{id}}` placeholder in the template and the JSX tag it sits on. + */ + id: string + /** Control-group label. Defaults to `id`. */ + label?: string + /** Prop metadata for this element (from the component's/child's docgen). */ + props: ReactDocgenProps + /** Per-section form config (typically an `include` list of props to expose). */ + config?: PropEditorConfig +} + export type PropEditorProps = { /** * The component's name. Used both to fetch its prop metadata (the doc JSON @@ -95,5 +114,25 @@ export type PropEditorProps = { * component registered in the docs globals (e.g. `"Button"`, `"Avatar"`). */ componentId: string + /** + * Prop metadata to build the form from. When supplied (e.g. by the docs + * `Document` page, which already has it), the editor uses it directly and + * skips the runtime fetch. When omitted (the README-embed case), the editor + * fetches `.json` itself. + */ + props?: ReactDocgenProps config?: PropEditorConfig + /** + * Composition mode: edit several elements' props at once (e.g. a `Menu` plus + * a representative `Menu.Item`). When provided together with `template`, the + * editor renders one control group per section and skips the single-element + * form. Each section's live attributes fill its `{{id}}` placeholder. + */ + sections?: PropEditorSection[] + /** + * JSX composition template used in composition mode. Contains one `{{id}}` + * placeholder per section, positioned where that element's attributes go β€” + * e.g. `\n Item\n`. + */ + template?: string } diff --git a/packages/__docs__/src/PropEditor/registry.ts b/packages/__docs__/src/PropEditor/registry.ts new file mode 100644 index 0000000000..c7c847f943 --- /dev/null +++ b/packages/__docs__/src/PropEditor/registry.ts @@ -0,0 +1,457 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { PropEditorConfig } from './props' + +/** + * A curated composition playground for a compound component, letting a reader + * edit both the outer element's props and a representative child's props. The + * `template` is JSX with one `{{id}}` placeholder per section, positioned where + * that element's attributes belong; each section's `config` typically carries + * an `include` list of the props to expose. + */ +export type CustomPlayground = { + sections: Array<{ id: string; label?: string; config?: PropEditorConfig }> + template: string +} + +/** + * Which docs pages get an auto-injected `PropEditor` and how it's configured. + * + * The docs `Document` page renders a playground for every component that + * {@link shouldAutoInject} accepts, using {@link getAutoInjectConfig} for any + * per-component tuning. This registry is the single place to opt a component + * out or adjust its generated form β€” no per-README edits required. + */ + +/** + * Components that must NOT get an auto-injected playground because they can't + * render standalone from a simple `` tag: render-prop components + * (children is a function), purely behavioral/positioning utilities, and + * portals that render their output elsewhere. Add ids here as broken previews + * surface β€” the preview itself is error-boundaried, so a missed one degrades + * to an error box rather than crashing the page. + */ +const NO_AUTO_INJECT = new Set([ + 'Focusable', // children is a render function + 'Selectable', // children is a render function + 'Dialog', // behavioral, no visual output of its own + 'Portal', // renders children into a detached node + 'Position', // positioning utility around a target/content pair + 'Transition' // needs children + an `in` toggle to show anything +]) + +/** + * Per-component overrides for the auto-injected playground, keyed by component + * id. Only components that need tuning appear here; everything else is driven + * entirely by react-docgen metadata. `sampleChildren` seeds the `children` + * control so the preview isn't empty on first render. + */ +const AUTO_INJECT_CONFIG: Record = { + Button: { sampleChildren: 'Click me' }, + CondensedButton: { sampleChildren: 'Click me' }, + Heading: { sampleChildren: 'Heading text' }, + Text: { sampleChildren: 'Some text' }, + Link: { sampleChildren: 'A link' }, + Pill: { sampleChildren: 'Pill' }, + Tag: { sampleChildren: 'Tag' }, + Byline: { sampleChildren: 'Byline content' }, + Alert: { sampleChildren: 'This is an alert' }, + ToggleDetails: { sampleChildren: 'Details content' } +} + +/** + * A minimal component doc has an id, a source extension, and prop metadata. + * `props` is kept loose (react-docgen's descriptor shape is wider than the + * subset {@link PropEditor} consumes); the gate only counts its keys. + */ +type AutoInjectDoc = { + id?: string + extension?: string + props?: Record +} + +/** + * Whether a component doc page should get an auto-injected playground. + * + * Skips: pages with no id, non-component pages (markdown/utility docs with no + * prop metadata), compound child components (e.g. `Menu.Item`) which only + * render inside a parent, and anything on the {@link NO_AUTO_INJECT} denylist. + */ +export function shouldAutoInject(doc: AutoInjectDoc): boolean { + const { id, extension, props } = doc + if (!id) return false + // Only real component source files carry a renderable tag + props. + if (extension && !['.js', '.ts', '.tsx'].includes(extension)) return false + if (!props || Object.keys(props).length === 0) return false + // Compound children (`Foo.Bar`) can't render without their parent. + if (id.includes('.')) return false + if (NO_AUTO_INJECT.has(id)) return false + return true +} + +/** Per-component playground config, or `{}` when none is registered. */ +export function getAutoInjectConfig(id: string): PropEditorConfig { + return AUTO_INJECT_CONFIG[id] ?? {} +} + +/** + * Components whose documented subcomponents are internal parts, not things a + * consumer composes (e.g. `Rating.Icon`, `Pagination.Page` in the v2 prop-driven + * API, `Calendar` which renders a full month on its own). Despite having dotted + * child docs, these are edited as a single element, so they get the simple form + * rather than being skipped as compound. + */ +const FORCE_SIMPLE = new Set(['Rating', 'Pagination', 'Calendar']) + +/** Whether a component with subcomponents should still get the simple form. */ +export function isForceSimple(id: string): boolean { + return FORCE_SIMPLE.has(id) +} + +/** + * Curated composition playgrounds for compound components, keyed by the outer + * component's id. A compound component only gets an auto-injected playground if + * it has an entry here β€” otherwise it falls back to its README examples (a bare + * `` with no children isn't a useful form). Section ids must match a + * `{{id}}` placeholder in the `template` and, for children, a child doc id + * (e.g. `Menu.Item`) so the editor can source that element's metadata. + */ +const CUSTOM_PLAYGROUNDS: Record = { + AppNav: { + sections: [ + { + id: 'AppNav.Item', + label: 'AppNav.Item (first item)', + config: { include: ['isSelected'] } + } + ], + template: ` + + + +` + }, + + Breadcrumb: { + sections: [{ id: 'Breadcrumb', config: { include: ['size'] } }], + template: ` + Dashboard + Courses + Assignments +` + }, + + DrawerLayout: { + sections: [ + { + id: 'DrawerLayout.Tray', + label: 'DrawerLayout.Tray', + config: { include: ['placement', 'border', 'shadow'] } + } + ], + template: ` + + + Tray content + + + Page content + + +` + }, + + Drilldown: { + sections: [ + { + id: 'Drilldown.Option', + label: 'Drilldown.Option (first option)', + config: { include: ['disabled'] } + } + ], + template: ` + + Option one + Option two + Option three + +` + }, + + Flex: { + sections: [ + { + id: 'Flex', + config: { + include: ['direction', 'alignItems', 'justifyItems', 'wrap'] + } + }, + { + id: 'Flex.Item', + label: 'Flex.Item (first item)', + config: { include: ['shouldGrow', 'shouldShrink'] } + } + ], + template: ` + + Item 1 + + + Item 2 + + + Item 3 + +` + }, + + Grid: { + sections: [ + { + id: 'Grid', + config: { + include: [ + 'colSpacing', + 'rowSpacing', + 'hAlign', + 'vAlign', + 'visualDebug' + ] + } + } + ], + template: ` + + Column one + Column two + Column three + +` + }, + + InlineList: { + sections: [ + { id: 'InlineList', config: { include: ['delimiter', 'size'] } } + ], + template: ` + Home + Courses + Grades +` + }, + + List: { + sections: [ + { id: 'List', config: { include: ['delimiter', 'isUnstyled', 'size'] } } + ], + template: ` + First item + Second item + Third item +` + }, + + Modal: { + sections: [{ id: 'Modal', config: { include: ['size', 'variant'] } }], + template: ` + + + Modal heading + + + Modal body content goes here. + + + + + +` + }, + + Options: { + sections: [ + { + id: 'Options.Item', + label: 'Options.Item (first item)', + config: { include: ['variant'] } + } + ], + template: ` + Option one + Option two + + Option three +` + }, + + Pages: { + sections: [ + { + id: 'Pages.Page', + label: 'Pages.Page (first page)', + config: { include: ['textAlign'] } + } + ], + template: ` + + First page content + + + Second page content + +` + }, + + Select: { + sections: [{ id: 'Select', config: { include: ['size', 'interaction'] } }], + template: `` + }, + + SideNavBar: { + sections: [ + { id: 'SideNavBar', config: { include: ['minimized'] } }, + { + id: 'SideNavBar.Item', + label: 'SideNavBar.Item (first item)', + config: { include: ['selected'] } + } + ], + template: ` + + } label="Inbox" href="#" {{SideNavBar.Item}} /> + } label="Courses" href="#" /> + } label="Calendar" href="#" /> + +` + }, + + SimpleSelect: { + sections: [ + { + id: 'SimpleSelect', + config: { include: ['size', 'interaction', 'isInline'] } + } + ], + template: ` + Option one + Option two + Option three +` + }, + + Table: { + sections: [{ id: 'Table', config: { include: ['hover', 'layout'] } }], + template: ` 'Top courses'} {{Table}}> + + + Name + Role + + + + + Alice + Teacher + + + Bob + Student + + +
` + }, + + Tabs: { + sections: [ + { id: 'Tabs', config: { include: ['variant'] } }, + { + id: 'Tabs.Panel', + label: 'Tabs.Panel (first panel)', + config: { include: ['isSelected', 'isDisabled'] } + } + ], + template: ` + + First panel + + + Second panel + + + Third panel + +` + }, + + Menu: { + sections: [ + { + id: 'Menu', + config: { + include: ['label', 'placement', 'withArrow', 'disabled'], + // `placement` is typed as the `PlacementPropValues` alias, so it can't + // be auto-inferred as a select β€” supply a curated set of options. + overrides: { + placement: { + control: 'select', + options: [ + 'top start', + 'top center', + 'top end', + 'bottom start', + 'bottom center', + 'bottom end', + 'start center', + 'end center' + ] + } + } + } + }, + { + id: 'Menu.Item', + label: 'Menu.Item (first item)', + config: { include: ['selected', 'type', 'disabled'] } + } + ], + // Matches the README example: a triggered popover menu. `defaultShow` keeps + // it open so prop changes stay visible in the preview. + template: `Menu} defaultShow {{Menu}}> + Configurable item + Another item + + Third item +` + } +} + +/** The curated composition playground for a component, if one is registered. */ +export function getCustomPlayground(id: string): CustomPlayground | undefined { + return CUSTOM_PLAYGROUNDS[id] +} diff --git a/packages/ui-buttons/src/Button/v2/README.md b/packages/ui-buttons/src/Button/v2/README.md index bb4fbaf381..e6bff47106 100644 --- a/packages/ui-buttons/src/Button/v2/README.md +++ b/packages/ui-buttons/src/Button/v2/README.md @@ -298,15 +298,6 @@ type: example render() ``` -### Playground - -```js ---- -type: embed ---- - -``` - ### Guidelines ```js