Skip to content

Commit 97d17fd

Browse files
balzssclaude
andcommitted
feat(docs-app,ui-buttons): add auto-generated prop playground
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 <noreply@anthropic.com>
1 parent 49b97ec commit 97d17fd

5 files changed

Lines changed: 607 additions & 1 deletion

File tree

packages/__docs__/globals.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ import placeholderImage from './buildScripts/samplemedia/placeholder-image'
6161
import ThemeColors from './src/ThemeColors'
6262
// eslint-disable-next-line no-restricted-imports
6363
import ColorTable from './src/ColorTable'
64+
// eslint-disable-next-line no-restricted-imports
65+
import { PropEditor } from './src/PropEditor'
6466

6567
import { additionalPrimitives, dataVisualization } from '@instructure/ui-themes'
6668

@@ -107,7 +109,8 @@ const globals: Record<string, any> = {
107109
additionalPrimitives,
108110
dataVisualization,
109111
ThemeColors,
110-
ColorTable
112+
ColorTable,
113+
PropEditor
111114
}
112115

113116
Object.keys(globals).forEach((key) => {
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
/*
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2015 - present Instructure, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
import { useContext, useEffect, useMemo, useState } from 'react'
26+
27+
import { View } from '@instructure/ui-view'
28+
import { Flex } from '@instructure/ui-flex'
29+
import { Text } from '@instructure/ui-text'
30+
import { Spinner } from '@instructure/ui-spinner'
31+
import { Checkbox } from '@instructure/ui-checkbox'
32+
import { TextInput } from '@instructure/ui-text-input'
33+
import { NumberInput } from '@instructure/ui-number-input'
34+
import { SimpleSelect } from '@instructure/ui-simple-select'
35+
import { SourceCodeEditor } from '@instructure/ui-source-code-editor'
36+
37+
import { AppContext } from '../appContext'
38+
import Preview from '../Preview'
39+
import { getDeployBase } from '../navigationUtils'
40+
41+
import { generateControls, serializeJsx } from './propControls'
42+
import type {
43+
Control,
44+
PropEditorProps,
45+
PropValue,
46+
ReactDocgenProps
47+
} from './props'
48+
49+
type Status = 'loading' | 'ready' | 'error'
50+
51+
const noop = () => {}
52+
53+
/**
54+
* An auto-generated, form-based playground for a component's props. Reads the
55+
* component's react-docgen metadata (fetched at runtime, the same JSON the
56+
* props table uses), derives a form control per prop, and renders a live
57+
* preview plus the equivalent JSX. Intended for use inside component READMEs
58+
* via a `type: embed` code block, e.g. `<PropEditor componentId="Button" />`.
59+
*
60+
* @private used only by the docs app.
61+
*/
62+
function PropEditor({ componentId, config = {} }: PropEditorProps) {
63+
const { componentVersion, themeKey, themes } = useContext(AppContext)
64+
const name = componentId
65+
66+
const [docgenProps, setDocgenProps] = useState<ReactDocgenProps | null>(null)
67+
const [status, setStatus] = useState<Status>('loading')
68+
const [errorMsg, setErrorMsg] = useState('')
69+
const [values, setValues] = useState<Record<string, PropValue>>({})
70+
71+
// A theme override local to the preview, so a reader can flip themes without
72+
// scrolling back to the page-level theme switcher. Seeded from the app's
73+
// selected theme and reset to it whenever that changes.
74+
const [selectedTheme, setSelectedTheme] = useState<string>(String(themeKey))
75+
useEffect(() => {
76+
setSelectedTheme(String(themeKey))
77+
}, [themeKey])
78+
79+
// The switchable themes, minus the shared-tokens bundle and the legacy
80+
// wrappers (v2 components use the new theming system).
81+
const themeOptions = useMemo(
82+
() =>
83+
Object.keys(themes || {}).filter(
84+
(key) => key !== 'shared-tokens' && !key.startsWith('legacy-')
85+
),
86+
[themes]
87+
)
88+
89+
// Fetch the component's prop metadata (mirrors App.getDocsBasePath).
90+
useEffect(() => {
91+
let cancelled = false
92+
const base = getDeployBase()
93+
const versionSeg = componentVersion ? `/${componentVersion}` : ''
94+
const url = `${base}/docs${versionSeg}/${name}.json`
95+
96+
setStatus('loading')
97+
fetch(url)
98+
.then((res) => {
99+
if (!res.ok) {
100+
throw new Error(`request failed (${res.status})`)
101+
}
102+
return res.json()
103+
})
104+
.then((data) => {
105+
if (cancelled) return
106+
setDocgenProps((data.props as ReactDocgenProps) || {})
107+
setStatus('ready')
108+
})
109+
.catch((err) => {
110+
if (cancelled) return
111+
setErrorMsg(err?.message || String(err))
112+
setStatus('error')
113+
})
114+
115+
return () => {
116+
cancelled = true
117+
}
118+
}, [name, componentVersion])
119+
120+
const { controls, skipped } = useMemo(
121+
() =>
122+
docgenProps
123+
? generateControls(docgenProps, config)
124+
: { controls: [] as Control[], skipped: [] as string[] },
125+
[docgenProps, config]
126+
)
127+
128+
// Seed the form with each control's default whenever the controls change.
129+
useEffect(() => {
130+
const initial: Record<string, PropValue> = {}
131+
controls.forEach((control) => {
132+
initial[control.name] = control.initialValue
133+
})
134+
setValues(initial)
135+
}, [controls])
136+
137+
const code = useMemo(
138+
() => serializeJsx(name, controls, values),
139+
[name, controls, values]
140+
)
141+
142+
const setValue = (propName: string, value: PropValue) => {
143+
setValues((prev) => ({ ...prev, [propName]: value }))
144+
}
145+
146+
const renderControl = (control: Control) => {
147+
const value = values[control.name]
148+
149+
if (control.type === 'boolean') {
150+
return (
151+
<Checkbox
152+
variant="toggle"
153+
size="small"
154+
label={control.name}
155+
checked={value === true}
156+
onChange={(event) => setValue(control.name, event.target.checked)}
157+
/>
158+
)
159+
}
160+
161+
if (control.type === 'select') {
162+
return (
163+
<SimpleSelect
164+
renderLabel={control.name}
165+
value={value == null ? '' : String(value)}
166+
onChange={(_event, { value: selected }) =>
167+
setValue(control.name, selected === '' ? undefined : selected)
168+
}
169+
>
170+
{!control.required && (
171+
<SimpleSelect.Option id={`${control.name}--unset`} value="">
172+
(unset)
173+
</SimpleSelect.Option>
174+
)}
175+
{(control.options || []).map((option) => (
176+
<SimpleSelect.Option
177+
key={option}
178+
id={`${control.name}--${option}`}
179+
value={option}
180+
>
181+
{option}
182+
</SimpleSelect.Option>
183+
))}
184+
</SimpleSelect>
185+
)
186+
}
187+
188+
if (control.type === 'number') {
189+
return (
190+
<NumberInput
191+
renderLabel={control.name}
192+
value={value == null ? '' : String(value)}
193+
onChange={(_event, val) =>
194+
setValue(control.name, val === '' ? undefined : Number(val))
195+
}
196+
/>
197+
)
198+
}
199+
200+
return (
201+
<TextInput
202+
renderLabel={control.name}
203+
value={value == null ? '' : String(value)}
204+
onChange={(_event, val) => setValue(control.name, val)}
205+
/>
206+
)
207+
}
208+
209+
if (status === 'loading') {
210+
return (
211+
<View as="div" padding="medium">
212+
<Spinner renderTitle="Loading props" size="x-small" />
213+
<View as="span" margin="0 0 0 small">
214+
<Text>Loading {name} props…</Text>
215+
</View>
216+
</View>
217+
)
218+
}
219+
220+
if (status === 'error') {
221+
return (
222+
<View as="div" padding="medium">
223+
<Text color="danger">
224+
Could not load props for <code>{name}</code>: {errorMsg}
225+
</Text>
226+
</View>
227+
)
228+
}
229+
230+
return (
231+
<View
232+
as="div"
233+
display="block"
234+
background="secondary"
235+
padding="medium"
236+
borderRadius="medium"
237+
margin="medium 0"
238+
>
239+
<Flex alignItems="stretch" gap="medium" wrap="wrap">
240+
{/* Controls column: narrow, holds the theme + prop selectors. */}
241+
<Flex.Item size="18rem" shouldGrow shouldShrink>
242+
{themeOptions.length > 1 && (
243+
<View as="div" margin="0 0 small 0">
244+
<SimpleSelect
245+
renderLabel="Theme"
246+
value={selectedTheme}
247+
onChange={(_event, { value: selected }) =>
248+
setSelectedTheme(String(selected))
249+
}
250+
>
251+
{themeOptions.map((option) => (
252+
<SimpleSelect.Option
253+
key={option}
254+
id={`theme--${option}`}
255+
value={option}
256+
>
257+
{option}
258+
</SimpleSelect.Option>
259+
))}
260+
</SimpleSelect>
261+
</View>
262+
)}
263+
<Text weight="bold">Props</Text>
264+
<View as="div" margin="small 0 0 0">
265+
{controls.map((control) => (
266+
<View key={control.name} as="div" margin="0 0 small 0">
267+
{renderControl(control)}
268+
</View>
269+
))}
270+
</View>
271+
{skipped.length > 0 && (
272+
<View as="div" margin="small 0 0 0">
273+
<Text size="small" color="secondary">
274+
Not editable here: {skipped.join(', ')}
275+
</Text>
276+
</View>
277+
)}
278+
</Flex.Item>
279+
280+
{/* Preview column: takes the remaining space beside the controls. */}
281+
<Flex.Item size="24rem" shouldGrow shouldShrink>
282+
<Text weight="bold">Preview</Text>
283+
<View as="div" margin="small 0 0 0">
284+
<Preview code={code} language="jsx" themeKey={selectedTheme} />
285+
</View>
286+
287+
<View as="div" margin="medium 0 0 0">
288+
<SourceCodeEditor
289+
label={`${name} code`}
290+
language="jsx"
291+
value={code}
292+
onChange={noop}
293+
readOnly
294+
lineWrapping
295+
/>
296+
</View>
297+
</Flex.Item>
298+
</Flex>
299+
</View>
300+
)
301+
}
302+
303+
PropEditor.displayName = 'PropEditor'
304+
305+
export default PropEditor
306+
export { PropEditor }

0 commit comments

Comments
 (0)