Skip to content

Commit eb42c0b

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 eb42c0b

5 files changed

Lines changed: 564 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: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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 { Text } from '@instructure/ui-text'
29+
import { Spinner } from '@instructure/ui-spinner'
30+
import { Checkbox } from '@instructure/ui-checkbox'
31+
import { TextInput } from '@instructure/ui-text-input'
32+
import { NumberInput } from '@instructure/ui-number-input'
33+
import { SimpleSelect } from '@instructure/ui-simple-select'
34+
import { SourceCodeEditor } from '@instructure/ui-source-code-editor'
35+
36+
import { AppContext } from '../appContext'
37+
import Preview from '../Preview'
38+
import { getDeployBase } from '../navigationUtils'
39+
40+
import { generateControls, serializeJsx } from './propControls'
41+
import type {
42+
Control,
43+
PropEditorProps,
44+
PropValue,
45+
ReactDocgenProps
46+
} from './props'
47+
48+
type Status = 'loading' | 'ready' | 'error'
49+
50+
const noop = () => {}
51+
52+
/**
53+
* An auto-generated, form-based playground for a component's props. Reads the
54+
* component's react-docgen metadata (fetched at runtime, the same JSON the
55+
* props table uses), derives a form control per prop, and renders a live
56+
* preview plus the equivalent JSX. Intended for use inside component READMEs
57+
* via a `type: embed` code block, e.g. `<PropEditor component={Button} />`.
58+
*
59+
* @private used only by the docs app.
60+
*/
61+
function PropEditor({ component, componentId, config = {} }: PropEditorProps) {
62+
const { componentVersion, themeKey } = useContext(AppContext)
63+
const name = componentId || component?.displayName || 'Component'
64+
65+
const [docgenProps, setDocgenProps] = useState<ReactDocgenProps | null>(null)
66+
const [status, setStatus] = useState<Status>('loading')
67+
const [errorMsg, setErrorMsg] = useState('')
68+
const [values, setValues] = useState<Record<string, PropValue>>({})
69+
70+
// Fetch the component's prop metadata (mirrors App.getDocsBasePath).
71+
useEffect(() => {
72+
let cancelled = false
73+
const base = getDeployBase()
74+
const versionSeg = componentVersion ? `/${componentVersion}` : ''
75+
const url = `${base}/docs${versionSeg}/${name}.json`
76+
77+
setStatus('loading')
78+
fetch(url)
79+
.then((res) => {
80+
if (!res.ok) {
81+
throw new Error(`request failed (${res.status})`)
82+
}
83+
return res.json()
84+
})
85+
.then((data) => {
86+
if (cancelled) return
87+
setDocgenProps((data.props as ReactDocgenProps) || {})
88+
setStatus('ready')
89+
})
90+
.catch((err) => {
91+
if (cancelled) return
92+
setErrorMsg(err?.message || String(err))
93+
setStatus('error')
94+
})
95+
96+
return () => {
97+
cancelled = true
98+
}
99+
}, [name, componentVersion])
100+
101+
const { controls, skipped } = useMemo(
102+
() =>
103+
docgenProps
104+
? generateControls(docgenProps, config)
105+
: { controls: [] as Control[], skipped: [] as string[] },
106+
[docgenProps, config]
107+
)
108+
109+
// Seed the form with each control's default whenever the controls change.
110+
useEffect(() => {
111+
const initial: Record<string, PropValue> = {}
112+
controls.forEach((control) => {
113+
initial[control.name] = control.initialValue
114+
})
115+
setValues(initial)
116+
}, [controls])
117+
118+
const code = useMemo(
119+
() => serializeJsx(name, controls, values),
120+
[name, controls, values]
121+
)
122+
123+
const setValue = (propName: string, value: PropValue) => {
124+
setValues((prev) => ({ ...prev, [propName]: value }))
125+
}
126+
127+
const renderControl = (control: Control) => {
128+
const value = values[control.name]
129+
130+
if (control.type === 'boolean') {
131+
return (
132+
<Checkbox
133+
variant="toggle"
134+
size="small"
135+
label={control.name}
136+
checked={value === true}
137+
onChange={(event) => setValue(control.name, event.target.checked)}
138+
/>
139+
)
140+
}
141+
142+
if (control.type === 'select') {
143+
return (
144+
<SimpleSelect
145+
renderLabel={control.name}
146+
value={value == null ? '' : String(value)}
147+
onChange={(_event, { value: selected }) =>
148+
setValue(control.name, selected === '' ? undefined : selected)
149+
}
150+
>
151+
{!control.required && (
152+
<SimpleSelect.Option id={`${control.name}--unset`} value="">
153+
(unset)
154+
</SimpleSelect.Option>
155+
)}
156+
{(control.options || []).map((option) => (
157+
<SimpleSelect.Option
158+
key={option}
159+
id={`${control.name}--${option}`}
160+
value={option}
161+
>
162+
{option}
163+
</SimpleSelect.Option>
164+
))}
165+
</SimpleSelect>
166+
)
167+
}
168+
169+
if (control.type === 'number') {
170+
return (
171+
<NumberInput
172+
renderLabel={control.name}
173+
value={value == null ? '' : String(value)}
174+
onChange={(_event, val) =>
175+
setValue(control.name, val === '' ? undefined : Number(val))
176+
}
177+
/>
178+
)
179+
}
180+
181+
return (
182+
<TextInput
183+
renderLabel={control.name}
184+
value={value == null ? '' : String(value)}
185+
onChange={(_event, val) => setValue(control.name, val)}
186+
/>
187+
)
188+
}
189+
190+
if (status === 'loading') {
191+
return (
192+
<View as="div" padding="medium">
193+
<Spinner renderTitle="Loading props" size="x-small" />
194+
<View as="span" margin="0 0 0 small">
195+
<Text>Loading {name} props…</Text>
196+
</View>
197+
</View>
198+
)
199+
}
200+
201+
if (status === 'error') {
202+
return (
203+
<View as="div" padding="medium">
204+
<Text color="danger">
205+
Could not load props for <code>{name}</code>: {errorMsg}
206+
</Text>
207+
</View>
208+
)
209+
}
210+
211+
return (
212+
<View
213+
as="div"
214+
display="block"
215+
background="secondary"
216+
padding="medium"
217+
borderRadius="medium"
218+
margin="medium 0"
219+
>
220+
<Text weight="bold">Preview</Text>
221+
<View as="div" margin="small 0 0 0">
222+
<Preview code={code} language="jsx" themeKey={themeKey} />
223+
</View>
224+
225+
<View as="div" margin="medium 0 0 0">
226+
<Text weight="bold">Props</Text>
227+
<View as="div" margin="small 0 0 0">
228+
{controls.map((control) => (
229+
<View key={control.name} as="div" margin="0 0 small 0">
230+
{renderControl(control)}
231+
</View>
232+
))}
233+
</View>
234+
{skipped.length > 0 && (
235+
<View as="div" margin="small 0 0 0">
236+
<Text size="small" color="secondary">
237+
Not editable here: {skipped.join(', ')}
238+
</Text>
239+
</View>
240+
)}
241+
</View>
242+
243+
<View as="div" margin="medium 0 0 0">
244+
<SourceCodeEditor
245+
label={`${name} code`}
246+
language="jsx"
247+
value={code}
248+
onChange={noop}
249+
readOnly
250+
lineWrapping
251+
/>
252+
</View>
253+
</View>
254+
)
255+
}
256+
257+
PropEditor.displayName = 'PropEditor'
258+
259+
export default PropEditor
260+
export { PropEditor }

0 commit comments

Comments
 (0)