dslinter 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/LICENSE +201 -0
- package/README.md +104 -0
- package/bin/dslinter.mjs +29 -0
- package/components.json +20 -0
- package/package.json +90 -0
- package/src/components/InlineCode.tsx +5 -0
- package/src/components/icons.tsx +121 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/checkbox.tsx +25 -0
- package/src/components/ui/command.tsx +183 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/hover-card.tsx +42 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/select.tsx +149 -0
- package/src/components/ui/table.tsx +118 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/dashboard/ComponentCatalog.tsx +210 -0
- package/src/dashboard/ComponentUsageDetails.tsx +109 -0
- package/src/dashboard/DashboardBody.tsx +71 -0
- package/src/dashboard/FindingsList.tsx +151 -0
- package/src/dashboard/ScoreStrip.tsx +28 -0
- package/src/dashboard/TokenWall.tsx +241 -0
- package/src/dashboard/aggregate.ts +73 -0
- package/src/dashboard/paths.ts +10 -0
- package/src/dashboard/useWorkspaceReport.ts +136 -0
- package/src/index.ts +67 -0
- package/src/lib/utils.ts +6 -0
- package/src/playground/definePlayground.tsx +99 -0
- package/src/playground/enumerateControlCombinations.test.ts +112 -0
- package/src/playground/enumerateControlCombinations.ts +74 -0
- package/src/report/a11yForModule.ts +35 -0
- package/src/report/codeScoreForModule.ts +41 -0
- package/src/report/modulePathMatch.ts +27 -0
- package/src/report/tokenStyleFindingsForModule.ts +24 -0
- package/src/shell/ComponentPlaygroundPane.tsx +438 -0
- package/src/shell/DashboardCommandPalette.tsx +134 -0
- package/src/shell/DashboardLayout.tsx +230 -0
- package/src/shell/EmptyCard.tsx +21 -0
- package/src/shell/GovernancePane.tsx +77 -0
- package/src/shell/PlaygroundA11yAndCode.tsx +387 -0
- package/src/shell/PlaygroundControlField.tsx +213 -0
- package/src/shell/PlaygroundControls.tsx +66 -0
- package/src/shell/PlaygroundUsageCode.tsx +51 -0
- package/src/shell/PlaygroundVariantMatrix.tsx +68 -0
- package/src/shell/Section.tsx +34 -0
- package/src/shell/Sidebar.tsx +203 -0
- package/src/shell/TokensPane.tsx +26 -0
- package/src/shell/controlApiTable.ts +53 -0
- package/src/shell/hashRoute.ts +49 -0
- package/src/shell/playgroundUsageHighlight.ts +53 -0
- package/src/shell/playgroundUsageTwoslash.ts +69 -0
- package/src/shell/useHashRoute.ts +29 -0
- package/src/styles/dashboard-theme.css +188 -0
- package/src/types/controls.ts +62 -0
- package/src/types/defaultTailwindTypography.ts +55 -0
- package/src/types/playground.ts +21 -0
- package/src/types/preview.ts +8 -0
- package/src/types/report.ts +116 -0
- package/src/types/tokenCatalog.ts +54 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
|
|
3
|
+
import type { PlaygroundEntry } from "../types/playground";
|
|
4
|
+
import type { A11yModuleSummary } from "../report/a11yForModule";
|
|
5
|
+
import type { CodeScoreModuleSummary } from "../report/codeScoreForModule";
|
|
6
|
+
import type { LintFinding, UsageSummary } from "../types/report";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import {
|
|
10
|
+
Table,
|
|
11
|
+
TableBody,
|
|
12
|
+
TableCell,
|
|
13
|
+
TableHead,
|
|
14
|
+
TableHeader,
|
|
15
|
+
TableRow,
|
|
16
|
+
} from "@/components/ui/table";
|
|
17
|
+
import { controlsToApiRows } from "./controlApiTable";
|
|
18
|
+
import { PlaygroundControlField } from "./PlaygroundControlField";
|
|
19
|
+
import { PlaygroundUsageCode } from "./PlaygroundUsageCode";
|
|
20
|
+
import { EmptyCard } from "./EmptyCard";
|
|
21
|
+
import { cn } from "../lib/utils";
|
|
22
|
+
import { Section } from "./Section";
|
|
23
|
+
|
|
24
|
+
type UsageProps = {
|
|
25
|
+
entry: PlaygroundEntry;
|
|
26
|
+
values: PlaygroundArgs;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function PlaygroundUsageSection({ entry, values }: UsageProps) {
|
|
30
|
+
const usage =
|
|
31
|
+
entry.usageSnippet?.(values) ??
|
|
32
|
+
`// Pass usageSnippet on this PlaygroundEntry, or derive snippets from dslint controls.\n<${entry.id} />`;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Section
|
|
36
|
+
id="usage"
|
|
37
|
+
title="Usage"
|
|
38
|
+
description="Example usage for the current playground values."
|
|
39
|
+
>
|
|
40
|
+
<PlaygroundUsageCode source={usage} />
|
|
41
|
+
</Section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type TokenStyleProps = {
|
|
46
|
+
findings: LintFinding[];
|
|
47
|
+
reportReady: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function PlaygroundTokenStyleSection({
|
|
51
|
+
findings,
|
|
52
|
+
reportReady,
|
|
53
|
+
}: TokenStyleProps) {
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
{reportReady && findings.length > 0 ? (
|
|
57
|
+
<Table>
|
|
58
|
+
<TableHeader>
|
|
59
|
+
<TableRow>
|
|
60
|
+
<TableHead>Rule</TableHead>
|
|
61
|
+
<TableHead>Line</TableHead>
|
|
62
|
+
<TableHead>Severity</TableHead>
|
|
63
|
+
<TableHead>Message</TableHead>
|
|
64
|
+
</TableRow>
|
|
65
|
+
</TableHeader>
|
|
66
|
+
<TableBody>
|
|
67
|
+
{findings.map((f, i) => (
|
|
68
|
+
<TableRow key={`${f.rule_id}-${f.line ?? "x"}-${i}`}>
|
|
69
|
+
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
70
|
+
{f.rule_id}
|
|
71
|
+
</TableCell>
|
|
72
|
+
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
73
|
+
{f.line ?? "—"}
|
|
74
|
+
</TableCell>
|
|
75
|
+
<TableCell>
|
|
76
|
+
<Badge variant="secondary" size="sm">
|
|
77
|
+
{f.severity}
|
|
78
|
+
</Badge>
|
|
79
|
+
</TableCell>
|
|
80
|
+
<TableCell>{f.message}</TableCell>
|
|
81
|
+
</TableRow>
|
|
82
|
+
))}
|
|
83
|
+
</TableBody>
|
|
84
|
+
</Table>
|
|
85
|
+
) : reportReady && findings.length === 0 ? (
|
|
86
|
+
<EmptyCard>
|
|
87
|
+
No hardcoded or arbitrary token color findings on this file in the
|
|
88
|
+
current report.
|
|
89
|
+
</EmptyCard>
|
|
90
|
+
) : (
|
|
91
|
+
<EmptyCard>
|
|
92
|
+
Token findings update when{" "}
|
|
93
|
+
<span className="font-mono">dslint-report.json</span> is available
|
|
94
|
+
(same fetch as Governance).
|
|
95
|
+
</EmptyCard>
|
|
96
|
+
)}
|
|
97
|
+
</>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type CodeScoreProps = {
|
|
102
|
+
codeScore: CodeScoreModuleSummary;
|
|
103
|
+
reportReady: boolean;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function PlaygroundCodeScoreSection({
|
|
107
|
+
codeScore,
|
|
108
|
+
reportReady,
|
|
109
|
+
}: CodeScoreProps) {
|
|
110
|
+
const { findings } = codeScore;
|
|
111
|
+
|
|
112
|
+
const hasFindingRows = reportReady && findings.length > 0;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
{hasFindingRows ? (
|
|
117
|
+
<>
|
|
118
|
+
<Table>
|
|
119
|
+
<TableHeader>
|
|
120
|
+
<TableRow>
|
|
121
|
+
<TableHead>Rule</TableHead>
|
|
122
|
+
<TableHead>Line</TableHead>
|
|
123
|
+
<TableHead>Severity</TableHead>
|
|
124
|
+
<TableHead>Message</TableHead>
|
|
125
|
+
</TableRow>
|
|
126
|
+
</TableHeader>
|
|
127
|
+
<TableBody>
|
|
128
|
+
{findings.map((f, i) => (
|
|
129
|
+
<TableRow key={`${f.rule_id}-${f.line ?? "x"}-${i}`}>
|
|
130
|
+
<TableCell>{f.rule_id}</TableCell>
|
|
131
|
+
<TableCell>{f.line ?? "—"}</TableCell>
|
|
132
|
+
<TableCell>{f.severity}</TableCell>
|
|
133
|
+
<TableCell>{f.message}</TableCell>
|
|
134
|
+
</TableRow>
|
|
135
|
+
))}
|
|
136
|
+
</TableBody>
|
|
137
|
+
</Table>
|
|
138
|
+
</>
|
|
139
|
+
) : reportReady && findings.length === 0 ? (
|
|
140
|
+
<EmptyCard>
|
|
141
|
+
No quality findings on this file in the current report.
|
|
142
|
+
</EmptyCard>
|
|
143
|
+
) : (
|
|
144
|
+
<EmptyCard>
|
|
145
|
+
Code score updates when{" "}
|
|
146
|
+
<span className="font-mono">dslint-report.json</span> is available
|
|
147
|
+
(same fetch as Governance).
|
|
148
|
+
</EmptyCard>
|
|
149
|
+
)}
|
|
150
|
+
</>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type A11yProps = {
|
|
155
|
+
a11y: A11yModuleSummary;
|
|
156
|
+
reportReady: boolean;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export function PlaygroundA11ySection({ a11y, reportReady }: A11yProps) {
|
|
160
|
+
const hasFindingRows = reportReady && a11y.findings.length > 0;
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<>
|
|
164
|
+
{hasFindingRows ? (
|
|
165
|
+
<Table>
|
|
166
|
+
<TableHeader>
|
|
167
|
+
<TableRow>
|
|
168
|
+
<TableHead>Rule</TableHead>
|
|
169
|
+
<TableHead>Line</TableHead>
|
|
170
|
+
<TableHead>Severity</TableHead>
|
|
171
|
+
<TableHead>Message</TableHead>
|
|
172
|
+
</TableRow>
|
|
173
|
+
</TableHeader>
|
|
174
|
+
<TableBody>
|
|
175
|
+
{a11y.findings.map((f, i) => (
|
|
176
|
+
<TableRow key={`${f.rule_id}-${f.line ?? "x"}-${i}`}>
|
|
177
|
+
<TableCell>{f.rule_id}</TableCell>
|
|
178
|
+
<TableCell>{f.line ?? "—"}</TableCell>
|
|
179
|
+
<TableCell>{f.severity}</TableCell>
|
|
180
|
+
<TableCell>{f.message}</TableCell>
|
|
181
|
+
</TableRow>
|
|
182
|
+
))}
|
|
183
|
+
</TableBody>
|
|
184
|
+
</Table>
|
|
185
|
+
) : reportReady && a11y.issueCount === 0 ? (
|
|
186
|
+
<EmptyCard>
|
|
187
|
+
No accessibility findings on this file in the current report.
|
|
188
|
+
</EmptyCard>
|
|
189
|
+
) : (
|
|
190
|
+
<EmptyCard>
|
|
191
|
+
A11y score updates when{" "}
|
|
192
|
+
<span className="font-mono">dslint-report.json</span> is available
|
|
193
|
+
(same fetch as Governance).
|
|
194
|
+
</EmptyCard>
|
|
195
|
+
)}
|
|
196
|
+
</>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type ApiProps = {
|
|
201
|
+
controls: PlaygroundControl[];
|
|
202
|
+
values: PlaygroundArgs;
|
|
203
|
+
onChange: (next: PlaygroundArgs) => void;
|
|
204
|
+
onReset: () => void;
|
|
205
|
+
/** When set, adds columns for how often each prop appears at scanned JSX call sites. */
|
|
206
|
+
reportUsage?: UsageSummary;
|
|
207
|
+
/** Declared prop names from the scan (definitions + playground specs), used for “never passed” hints. */
|
|
208
|
+
declaredPropsFromScan?: string[];
|
|
209
|
+
/** True when `dslint-report.json` is loaded (even if this component has no usage row). */
|
|
210
|
+
governanceReportLoaded?: boolean;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function formatRepoLiteralChips(
|
|
214
|
+
byVal: Record<string, number> | undefined,
|
|
215
|
+
max = 6,
|
|
216
|
+
): string {
|
|
217
|
+
if (!byVal || Object.keys(byVal).length === 0) return "—";
|
|
218
|
+
const entries = Object.entries(byVal).sort((x, y) => y[1] - x[1]);
|
|
219
|
+
const shown = entries.slice(0, max);
|
|
220
|
+
const tail = Math.max(0, entries.length - max);
|
|
221
|
+
return (
|
|
222
|
+
shown.map(([val, n]) => `${JSON.stringify(val)} ×${n}`).join(" · ") +
|
|
223
|
+
(tail > 0 ? ` · +${tail}` : "")
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function PlaygroundApiReference({
|
|
228
|
+
controls,
|
|
229
|
+
values,
|
|
230
|
+
onChange,
|
|
231
|
+
onReset,
|
|
232
|
+
reportUsage,
|
|
233
|
+
declaredPropsFromScan: _declaredPropsFromScan = [],
|
|
234
|
+
governanceReportLoaded: _governanceReportLoaded = false,
|
|
235
|
+
}: ApiProps) {
|
|
236
|
+
if (controls.length === 0) return null;
|
|
237
|
+
|
|
238
|
+
const patch = useCallback(
|
|
239
|
+
(key: string, value: string | number | boolean) => {
|
|
240
|
+
onChange({ ...values, [key]: value });
|
|
241
|
+
},
|
|
242
|
+
[onChange, values],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const rows = controlsToApiRows(controls);
|
|
246
|
+
const showRepo = reportUsage != null;
|
|
247
|
+
const freqs = reportUsage?.prop_frequencies ?? {};
|
|
248
|
+
const valueFreqs = reportUsage?.prop_value_frequencies ?? {};
|
|
249
|
+
const controlKeys = new Set(rows.map((r) => r.prop));
|
|
250
|
+
const extraRepoProps = showRepo
|
|
251
|
+
? Object.keys(freqs)
|
|
252
|
+
.filter((k) => !controlKeys.has(k))
|
|
253
|
+
.sort((a, b) => a.localeCompare(b))
|
|
254
|
+
: [];
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<Section
|
|
258
|
+
id="api-reference"
|
|
259
|
+
title="API reference"
|
|
260
|
+
description=""
|
|
261
|
+
actions={
|
|
262
|
+
<Button type="button" variant="outline" size="sm" onClick={onReset}>
|
|
263
|
+
Reset defaults
|
|
264
|
+
</Button>
|
|
265
|
+
}
|
|
266
|
+
>
|
|
267
|
+
<Table>
|
|
268
|
+
<TableHeader>
|
|
269
|
+
<TableRow>
|
|
270
|
+
<TableHead>Prop</TableHead>
|
|
271
|
+
<TableHead>Type</TableHead>
|
|
272
|
+
<TableHead>Value</TableHead>
|
|
273
|
+
{showRepo ? (
|
|
274
|
+
<>
|
|
275
|
+
<TableHead>Usage</TableHead>
|
|
276
|
+
<TableHead>Values</TableHead>
|
|
277
|
+
</>
|
|
278
|
+
) : null}
|
|
279
|
+
</TableRow>
|
|
280
|
+
</TableHeader>
|
|
281
|
+
<TableBody>
|
|
282
|
+
{controls.map((c) => {
|
|
283
|
+
const r = rows.find((row) => row.prop === c.key);
|
|
284
|
+
if (!r) return null;
|
|
285
|
+
const n = showRepo ? (freqs[r.prop] ?? 0) : 0;
|
|
286
|
+
const valueChips = formatRepoLiteralChips(valueFreqs[r.prop]);
|
|
287
|
+
return (
|
|
288
|
+
<TableRow key={r.prop}>
|
|
289
|
+
<TableCell className="font-medium">{r.prop}</TableCell>
|
|
290
|
+
<TableCell>
|
|
291
|
+
{c.type === "select" ? (
|
|
292
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
293
|
+
{c.options.map((o) => {
|
|
294
|
+
const current = String(
|
|
295
|
+
values[c.key] ?? c.default ?? "",
|
|
296
|
+
);
|
|
297
|
+
const selected = current === o.value;
|
|
298
|
+
return (
|
|
299
|
+
<Badge
|
|
300
|
+
key={o.value}
|
|
301
|
+
variant="outline"
|
|
302
|
+
size="sm"
|
|
303
|
+
asChild
|
|
304
|
+
className={cn(
|
|
305
|
+
selected &&
|
|
306
|
+
"border-transparent bg-primary text-primary-foreground hover:bg-primary/90",
|
|
307
|
+
)}
|
|
308
|
+
>
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
className="cursor-pointer"
|
|
312
|
+
onClick={() => patch(c.key, o.value)}
|
|
313
|
+
aria-pressed={selected}
|
|
314
|
+
aria-label={`Set ${c.label} to ${o.label}`}
|
|
315
|
+
>
|
|
316
|
+
{o.value}
|
|
317
|
+
</button>
|
|
318
|
+
</Badge>
|
|
319
|
+
);
|
|
320
|
+
})}
|
|
321
|
+
</div>
|
|
322
|
+
) : (
|
|
323
|
+
<span className="font-mono text-xs flex items-center gap-1">
|
|
324
|
+
{r.type}
|
|
325
|
+
{r.default !== "—" ? (
|
|
326
|
+
<Badge variant="secondary" size="sm">
|
|
327
|
+
{r.default}
|
|
328
|
+
</Badge>
|
|
329
|
+
) : null}
|
|
330
|
+
</span>
|
|
331
|
+
)}
|
|
332
|
+
</TableCell>
|
|
333
|
+
<TableCell>
|
|
334
|
+
<PlaygroundControlField
|
|
335
|
+
control={c}
|
|
336
|
+
values={values}
|
|
337
|
+
patch={patch}
|
|
338
|
+
idPrefix="api"
|
|
339
|
+
layout="table"
|
|
340
|
+
/>
|
|
341
|
+
</TableCell>
|
|
342
|
+
{showRepo ? (
|
|
343
|
+
<>
|
|
344
|
+
<TableCell>{n}</TableCell>
|
|
345
|
+
<TableCell>{valueChips}</TableCell>
|
|
346
|
+
</>
|
|
347
|
+
) : null}
|
|
348
|
+
</TableRow>
|
|
349
|
+
);
|
|
350
|
+
})}
|
|
351
|
+
</TableBody>
|
|
352
|
+
</Table>
|
|
353
|
+
|
|
354
|
+
{showRepo && extraRepoProps.length > 0 ? (
|
|
355
|
+
<div className="mt-4">
|
|
356
|
+
<h3 className="text-sm font-semibold text-foreground">
|
|
357
|
+
Also seen in repo (not in playground)
|
|
358
|
+
</h3>
|
|
359
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
360
|
+
These prop names appear in scanned JSX but are not wired as
|
|
361
|
+
playground controls on this page.
|
|
362
|
+
</p>
|
|
363
|
+
<Table>
|
|
364
|
+
<TableHeader>
|
|
365
|
+
<TableRow>
|
|
366
|
+
<TableHead>Prop</TableHead>
|
|
367
|
+
<TableHead>Repo call sites</TableHead>
|
|
368
|
+
<TableHead>Repo literals</TableHead>
|
|
369
|
+
</TableRow>
|
|
370
|
+
</TableHeader>
|
|
371
|
+
<TableBody>
|
|
372
|
+
{extraRepoProps.map((prop) => (
|
|
373
|
+
<TableRow key={prop}>
|
|
374
|
+
<TableCell>{prop}</TableCell>
|
|
375
|
+
<TableCell>×{freqs[prop] ?? 0}</TableCell>
|
|
376
|
+
<TableCell>
|
|
377
|
+
{formatRepoLiteralChips(valueFreqs[prop])}
|
|
378
|
+
</TableCell>
|
|
379
|
+
</TableRow>
|
|
380
|
+
))}
|
|
381
|
+
</TableBody>
|
|
382
|
+
</Table>
|
|
383
|
+
</div>
|
|
384
|
+
) : null}
|
|
385
|
+
</Section>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
|
|
2
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
3
|
+
import { Input } from "@/components/ui/input";
|
|
4
|
+
import { Label } from "@/components/ui/label";
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from "@/components/ui/select";
|
|
12
|
+
|
|
13
|
+
const labelClass = "text-xs font-medium text-muted-foreground";
|
|
14
|
+
|
|
15
|
+
export type PlaygroundControlFieldProps = {
|
|
16
|
+
control: PlaygroundControl;
|
|
17
|
+
values: PlaygroundArgs;
|
|
18
|
+
patch: (key: string, value: string | number | boolean) => void;
|
|
19
|
+
/** Prefix for element ids (e.g. `ctrl` vs `api`). */
|
|
20
|
+
idPrefix: string;
|
|
21
|
+
layout: "grid" | "table";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function PlaygroundControlField({
|
|
25
|
+
control: c,
|
|
26
|
+
values,
|
|
27
|
+
patch,
|
|
28
|
+
idPrefix,
|
|
29
|
+
layout,
|
|
30
|
+
}: PlaygroundControlFieldProps) {
|
|
31
|
+
const id = `${idPrefix}-${c.key}`;
|
|
32
|
+
|
|
33
|
+
if (layout === "grid") {
|
|
34
|
+
switch (c.type) {
|
|
35
|
+
case "boolean": {
|
|
36
|
+
const checked = Boolean(values[c.key]);
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex flex-col gap-2">
|
|
39
|
+
<div className="flex items-center gap-2">
|
|
40
|
+
<Checkbox
|
|
41
|
+
id={id}
|
|
42
|
+
checked={checked}
|
|
43
|
+
onCheckedChange={(v: boolean | "indeterminate") =>
|
|
44
|
+
patch(c.key, v === true)
|
|
45
|
+
}
|
|
46
|
+
/>
|
|
47
|
+
<Label
|
|
48
|
+
htmlFor={id}
|
|
49
|
+
className={`${labelClass} cursor-pointer font-normal`}
|
|
50
|
+
>
|
|
51
|
+
{c.label}
|
|
52
|
+
</Label>
|
|
53
|
+
</div>
|
|
54
|
+
{c.hint ? (
|
|
55
|
+
<p className="text-xs text-muted-foreground">{c.hint}</p>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
case "string":
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex min-w-0 flex-col gap-1.5">
|
|
63
|
+
<Label htmlFor={id} className={labelClass}>
|
|
64
|
+
{c.label}
|
|
65
|
+
</Label>
|
|
66
|
+
<Input
|
|
67
|
+
id={id}
|
|
68
|
+
type="text"
|
|
69
|
+
value={String(values[c.key] ?? "")}
|
|
70
|
+
placeholder={c.placeholder}
|
|
71
|
+
onChange={(e) => patch(c.key, e.target.value)}
|
|
72
|
+
className="h-8 text-xs"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
case "number": {
|
|
77
|
+
const raw = values[c.key];
|
|
78
|
+
const parsed =
|
|
79
|
+
typeof raw === "number" && Number.isFinite(raw) ? raw : Number(raw);
|
|
80
|
+
const safe = Number.isFinite(parsed) ? parsed : c.default;
|
|
81
|
+
return (
|
|
82
|
+
<div className="flex min-w-0 flex-col gap-1.5">
|
|
83
|
+
<Label htmlFor={id} className={labelClass}>
|
|
84
|
+
{c.label}
|
|
85
|
+
</Label>
|
|
86
|
+
<Input
|
|
87
|
+
id={id}
|
|
88
|
+
type="number"
|
|
89
|
+
value={safe}
|
|
90
|
+
min={c.min}
|
|
91
|
+
max={c.max}
|
|
92
|
+
step={c.step ?? 1}
|
|
93
|
+
onChange={(e) => {
|
|
94
|
+
const v = e.target.valueAsNumber;
|
|
95
|
+
patch(c.key, Number.isFinite(v) ? v : c.default);
|
|
96
|
+
}}
|
|
97
|
+
className="h-8 text-xs"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
case "select": {
|
|
103
|
+
const v = String(values[c.key] ?? c.default ?? "");
|
|
104
|
+
return (
|
|
105
|
+
<div className="flex min-w-0 flex-col gap-1.5">
|
|
106
|
+
<Label htmlFor={id} className={labelClass}>
|
|
107
|
+
{c.label}
|
|
108
|
+
</Label>
|
|
109
|
+
<Select
|
|
110
|
+
value={v}
|
|
111
|
+
onValueChange={(next: string) => patch(c.key, next)}
|
|
112
|
+
>
|
|
113
|
+
<SelectTrigger id={id} className="h-8 text-xs">
|
|
114
|
+
<SelectValue placeholder={c.label} />
|
|
115
|
+
</SelectTrigger>
|
|
116
|
+
<SelectContent>
|
|
117
|
+
{c.options.map((o) => (
|
|
118
|
+
<SelectItem key={o.value} value={o.value} className="text-xs">
|
|
119
|
+
{o.label}
|
|
120
|
+
</SelectItem>
|
|
121
|
+
))}
|
|
122
|
+
</SelectContent>
|
|
123
|
+
</Select>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// table: prop name is in its own column; compact editors only
|
|
133
|
+
switch (c.type) {
|
|
134
|
+
case "boolean": {
|
|
135
|
+
const checked = Boolean(values[c.key]);
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex min-w-[8rem] flex-col gap-1">
|
|
138
|
+
<div className="flex items-center gap-2">
|
|
139
|
+
<Checkbox
|
|
140
|
+
id={id}
|
|
141
|
+
checked={checked}
|
|
142
|
+
aria-label={c.label}
|
|
143
|
+
onCheckedChange={(v: boolean | "indeterminate") =>
|
|
144
|
+
patch(c.key, v === true)
|
|
145
|
+
}
|
|
146
|
+
/>
|
|
147
|
+
<span className="text-xs text-muted-foreground">{c.label}</span>
|
|
148
|
+
</div>
|
|
149
|
+
{c.hint ? (
|
|
150
|
+
<p className="text-xs text-muted-foreground">{c.hint}</p>
|
|
151
|
+
) : null}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
case "string":
|
|
156
|
+
return (
|
|
157
|
+
<Input
|
|
158
|
+
id={id}
|
|
159
|
+
type="text"
|
|
160
|
+
value={String(values[c.key] ?? "")}
|
|
161
|
+
placeholder={c.placeholder}
|
|
162
|
+
onChange={(e) => patch(c.key, e.target.value)}
|
|
163
|
+
className="h-8 min-w-[10rem] max-w-xs text-xs"
|
|
164
|
+
aria-label={c.label}
|
|
165
|
+
/>
|
|
166
|
+
);
|
|
167
|
+
case "number": {
|
|
168
|
+
const raw = values[c.key];
|
|
169
|
+
const parsed =
|
|
170
|
+
typeof raw === "number" && Number.isFinite(raw) ? raw : Number(raw);
|
|
171
|
+
const safe = Number.isFinite(parsed) ? parsed : c.default;
|
|
172
|
+
return (
|
|
173
|
+
<Input
|
|
174
|
+
id={id}
|
|
175
|
+
type="number"
|
|
176
|
+
value={safe}
|
|
177
|
+
min={c.min}
|
|
178
|
+
max={c.max}
|
|
179
|
+
step={c.step ?? 1}
|
|
180
|
+
onChange={(e) => {
|
|
181
|
+
const v = e.target.valueAsNumber;
|
|
182
|
+
patch(c.key, Number.isFinite(v) ? v : c.default);
|
|
183
|
+
}}
|
|
184
|
+
className="h-8 w-24 text-xs"
|
|
185
|
+
aria-label={c.label}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
case "select": {
|
|
190
|
+
const v = String(values[c.key] ?? c.default ?? "");
|
|
191
|
+
return (
|
|
192
|
+
<Select value={v} onValueChange={(next: string) => patch(c.key, next)}>
|
|
193
|
+
<SelectTrigger
|
|
194
|
+
id={id}
|
|
195
|
+
className="h-8 min-w-[10rem] max-w-xs text-xs"
|
|
196
|
+
aria-label={c.label}
|
|
197
|
+
>
|
|
198
|
+
<SelectValue placeholder={c.label} />
|
|
199
|
+
</SelectTrigger>
|
|
200
|
+
<SelectContent>
|
|
201
|
+
{c.options.map((o) => (
|
|
202
|
+
<SelectItem key={o.value} value={o.value} className="text-xs">
|
|
203
|
+
{o.label}
|
|
204
|
+
</SelectItem>
|
|
205
|
+
))}
|
|
206
|
+
</SelectContent>
|
|
207
|
+
</Select>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
default:
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { PlaygroundControlField } from "./PlaygroundControlField";
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
controls: PlaygroundControl[];
|
|
8
|
+
values: PlaygroundArgs;
|
|
9
|
+
onChange: (next: PlaygroundArgs) => void;
|
|
10
|
+
onReset: () => void;
|
|
11
|
+
/** Omit the outer card wrapper (doc-style pages provide their own section chrome). */
|
|
12
|
+
bare?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function PlaygroundControls({
|
|
16
|
+
controls,
|
|
17
|
+
values,
|
|
18
|
+
onChange,
|
|
19
|
+
onReset,
|
|
20
|
+
bare,
|
|
21
|
+
}: Props) {
|
|
22
|
+
const patch = useCallback(
|
|
23
|
+
(key: string, value: string | number | boolean) => {
|
|
24
|
+
onChange({ ...values, [key]: value });
|
|
25
|
+
},
|
|
26
|
+
[onChange, values],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (controls.length === 0) return null;
|
|
30
|
+
|
|
31
|
+
const inner = (
|
|
32
|
+
<>
|
|
33
|
+
<div className="flex p-3 pl-5 flex-wrap items-center justify-between gap-3 border-b border-border pb-3">
|
|
34
|
+
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
35
|
+
Controls
|
|
36
|
+
</p>
|
|
37
|
+
<Button type="button" variant="outline" size="sm" onClick={onReset}>
|
|
38
|
+
Reset defaults
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
<div className=" p-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
42
|
+
{controls.map((c) => (
|
|
43
|
+
<div key={c.key}>
|
|
44
|
+
<PlaygroundControlField
|
|
45
|
+
control={c}
|
|
46
|
+
values={values}
|
|
47
|
+
patch={patch}
|
|
48
|
+
idPrefix="ctrl"
|
|
49
|
+
layout="grid"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (bare) {
|
|
58
|
+
return <div className="w-full">{inner}</div>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="mb-6 w-full rounded-ds-lg border border-border bg-card p-4 text-card-foreground shadow-xs">
|
|
63
|
+
{inner}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|