@spunto/design-system 0.10.0 → 0.11.1
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/README.md +85 -3
- package/package.json +9 -1
- package/src/components/devcontainer/catalog-card.tsx +269 -0
- package/src/components/devcontainer/catalog-tile.tsx +133 -0
- package/src/components/devcontainer/extension-card.tsx +146 -0
- package/src/components/devcontainer/feature-card.tsx +157 -0
- package/src/components/devcontainer/feature-tile.tsx +63 -0
- package/src/components/devcontainer/image-card.tsx +107 -0
- package/src/components/devcontainer/image-tile.tsx +56 -0
- package/src/components/devcontainer/index.ts +68 -0
- package/src/components/devcontainer/publisher-badge.tsx +91 -0
- package/src/components/devcontainer/registry-ref.tsx +89 -0
- package/src/components/devcontainer/registry.ts +154 -0
- package/src/components/devcontainer/stack-mark-paths.ts +60 -0
- package/src/components/devcontainer/stack-mark.tsx +92 -0
- package/src/components/devcontainer/types.ts +84 -0
- package/src/components/projects/advanced-sections.tsx +148 -0
- package/src/components/projects/build-manifest.tsx +116 -0
- package/src/components/projects/extension-picker.tsx +169 -0
- package/src/components/projects/feature-picker.tsx +193 -0
- package/src/components/projects/form-section.tsx +97 -0
- package/src/components/projects/image-picker.tsx +206 -0
- package/src/components/projects/index.ts +58 -0
- package/src/components/projects/project-form.tsx +495 -0
- package/src/components/projects/repo-list.tsx +302 -0
- package/src/components/projects/secret-list.tsx +159 -0
- package/src/components/projects/types.ts +136 -0
- package/styles.css +13 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { CheckIcon, TerminalIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { Button } from "../button"
|
|
8
|
+
|
|
9
|
+
export interface ManifestRow {
|
|
10
|
+
label: string
|
|
11
|
+
value: string
|
|
12
|
+
/** Drawn as ticked. "Filled in", not "valid" — the form doesn't grade you. */
|
|
13
|
+
done?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BuildManifestProps {
|
|
17
|
+
rows: ManifestRow[]
|
|
18
|
+
/** Rendered as the panel's primary action. Its `type="submit"` reaches the enclosing form. */
|
|
19
|
+
submitLabel?: ReactNode
|
|
20
|
+
submittingLabel?: ReactNode
|
|
21
|
+
submitting?: boolean
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
/** Error banner above the button — a failed mutation, typically. */
|
|
24
|
+
error?: string | null
|
|
25
|
+
/** Small print under the button. */
|
|
26
|
+
caption?: ReactNode
|
|
27
|
+
/** Anything after the caption (an "or start from a template" link). */
|
|
28
|
+
footer?: ReactNode
|
|
29
|
+
title?: string
|
|
30
|
+
className?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The live recap of a long form, as a terminal-ish panel: one line per section,
|
|
35
|
+
* ticked as you fill it, then the submit button.
|
|
36
|
+
*
|
|
37
|
+
* It's a **mirror, not a source** — it reads values the form already owns and
|
|
38
|
+
* never writes any. That's what lets it be sticky next to a seven-section form
|
|
39
|
+
* without becoming a second copy of the state, and what lets an app drop it
|
|
40
|
+
* entirely (`manifest={false}`) and put its own footer instead.
|
|
41
|
+
*/
|
|
42
|
+
export function BuildManifest({
|
|
43
|
+
rows,
|
|
44
|
+
submitLabel = "Save",
|
|
45
|
+
submittingLabel = "Saving…",
|
|
46
|
+
submitting = false,
|
|
47
|
+
disabled = false,
|
|
48
|
+
error,
|
|
49
|
+
caption,
|
|
50
|
+
footer,
|
|
51
|
+
title = "build manifest",
|
|
52
|
+
className,
|
|
53
|
+
}: BuildManifestProps) {
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
data-slot="build-manifest"
|
|
57
|
+
className={cn("overflow-hidden rounded-xl border border-border bg-card", className)}
|
|
58
|
+
>
|
|
59
|
+
{/* Window chrome in the three pillar colours — the same "this is a machine
|
|
60
|
+
artefact" cue as TerminalPanel, so the recap reads as the manifest a
|
|
61
|
+
worker will be built from rather than as a form sidebar. */}
|
|
62
|
+
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2.5">
|
|
63
|
+
<div className="flex gap-1.5">
|
|
64
|
+
<span className="h-2.5 w-2.5 rounded-full bg-run/70" />
|
|
65
|
+
<span className="h-2.5 w-2.5 rounded-full bg-build/70" />
|
|
66
|
+
<span className="h-2.5 w-2.5 rounded-full bg-ship/70" />
|
|
67
|
+
</div>
|
|
68
|
+
<div className="ml-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
69
|
+
<TerminalIcon className="h-3.5 w-3.5" />
|
|
70
|
+
{title}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div className="space-y-4 p-5">
|
|
75
|
+
<dl className="space-y-2.5">
|
|
76
|
+
{rows.map((row) => (
|
|
77
|
+
<div key={row.label} className="flex items-center gap-3 text-sm">
|
|
78
|
+
<span
|
|
79
|
+
className={cn(
|
|
80
|
+
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition-colors",
|
|
81
|
+
row.done ? "border-primary bg-primary text-background" : "border-border text-transparent"
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
<CheckIcon className="h-2.5 w-2.5" strokeWidth={3} />
|
|
85
|
+
</span>
|
|
86
|
+
<dt className="w-16 shrink-0 font-mono text-[11px] tracking-wide text-muted-foreground uppercase">
|
|
87
|
+
{row.label}
|
|
88
|
+
</dt>
|
|
89
|
+
<dd className="min-w-0 flex-1 truncate text-right font-medium">{row.value}</dd>
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</dl>
|
|
93
|
+
|
|
94
|
+
{error && (
|
|
95
|
+
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
96
|
+
{error}
|
|
97
|
+
</p>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<Button type="submit" size="lg" disabled={submitting || disabled} className="group w-full">
|
|
101
|
+
{submitting ? (
|
|
102
|
+
<span className="flex items-center gap-2">
|
|
103
|
+
<span className="h-4 w-4 animate-spin rounded-full border-2 border-background/40 border-t-background" />
|
|
104
|
+
{submittingLabel}
|
|
105
|
+
</span>
|
|
106
|
+
) : (
|
|
107
|
+
submitLabel
|
|
108
|
+
)}
|
|
109
|
+
</Button>
|
|
110
|
+
|
|
111
|
+
{caption && <p className="text-center text-[11px] text-muted-foreground">{caption}</p>}
|
|
112
|
+
{footer}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react"
|
|
4
|
+
import { Loader2Icon, SearchIcon, XIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { ExtensionCard, ExtensionCardSkeleton } from "../devcontainer/extension-card"
|
|
8
|
+
import { extensionId } from "../devcontainer/registry"
|
|
9
|
+
import type { VscodeExtensionEntry } from "../devcontainer/types"
|
|
10
|
+
import { Input } from "../input"
|
|
11
|
+
|
|
12
|
+
export interface ExtensionPickerProps {
|
|
13
|
+
/** The ids a project stores — `publisher.name`. */
|
|
14
|
+
value: string[]
|
|
15
|
+
onChange: (value: string[]) => void
|
|
16
|
+
/**
|
|
17
|
+
* Searches a marketplace. A callback, not a fetch: the package never talks to
|
|
18
|
+
* open-vsx.org itself, and an app without one still gets a working picker
|
|
19
|
+
* where ids are typed by hand.
|
|
20
|
+
*/
|
|
21
|
+
onSearch?: (query: string) => Promise<VscodeExtensionEntry[]>
|
|
22
|
+
/** Debounce before firing `onSearch`, ms. */
|
|
23
|
+
debounceMs?: number
|
|
24
|
+
className?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pick the VS Code extensions installed into code-server: a marketplace search
|
|
29
|
+
* rendering `ExtensionCard`s, plus the picked list.
|
|
30
|
+
*
|
|
31
|
+
* The payloads of everything picked this session are kept around, so a selected
|
|
32
|
+
* extension keeps its icon and publisher in the list below instead of collapsing
|
|
33
|
+
* back to a bare id the moment the results are cleared — the project only ever
|
|
34
|
+
* stores the id, but the screen doesn't have to look like it.
|
|
35
|
+
*/
|
|
36
|
+
export function ExtensionPicker({ value, onChange, onSearch, debounceMs = 300, className }: ExtensionPickerProps) {
|
|
37
|
+
const [query, setQuery] = useState("")
|
|
38
|
+
const [results, setResults] = useState<VscodeExtensionEntry[]>([])
|
|
39
|
+
const [known, setKnown] = useState<Record<string, VscodeExtensionEntry>>({})
|
|
40
|
+
const [loading, setLoading] = useState(false)
|
|
41
|
+
const [open, setOpen] = useState(false)
|
|
42
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
43
|
+
// Guards against an earlier, slower search landing after a later one and
|
|
44
|
+
// overwriting its results — the classic out-of-order autocomplete bug.
|
|
45
|
+
const runId = useRef(0)
|
|
46
|
+
|
|
47
|
+
useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
|
|
48
|
+
|
|
49
|
+
function handleQuery(next: string) {
|
|
50
|
+
setQuery(next)
|
|
51
|
+
if (timer.current) clearTimeout(timer.current)
|
|
52
|
+
if (!next.trim() || !onSearch) {
|
|
53
|
+
setResults([])
|
|
54
|
+
setOpen(false)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
setLoading(true)
|
|
58
|
+
setOpen(true)
|
|
59
|
+
const run = ++runId.current
|
|
60
|
+
timer.current = setTimeout(async () => {
|
|
61
|
+
let found: VscodeExtensionEntry[] = []
|
|
62
|
+
try {
|
|
63
|
+
found = await onSearch(next)
|
|
64
|
+
} catch {
|
|
65
|
+
found = []
|
|
66
|
+
}
|
|
67
|
+
if (run !== runId.current) return
|
|
68
|
+
setResults(found)
|
|
69
|
+
setLoading(false)
|
|
70
|
+
}, debounceMs)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function add(extension: VscodeExtensionEntry) {
|
|
74
|
+
const id = extensionId(extension)
|
|
75
|
+
if (!id) return
|
|
76
|
+
setKnown((prev) => ({ ...prev, [id]: extension }))
|
|
77
|
+
if (!value.includes(id)) onChange([...value, id])
|
|
78
|
+
reset()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function addTyped() {
|
|
82
|
+
const id = query.trim()
|
|
83
|
+
if (!id || value.includes(id)) return
|
|
84
|
+
onChange([...value, id])
|
|
85
|
+
reset()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function reset() {
|
|
89
|
+
setQuery("")
|
|
90
|
+
setResults([])
|
|
91
|
+
setOpen(false)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className={cn("space-y-2", className)}>
|
|
96
|
+
<div>
|
|
97
|
+
<div className="flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5">
|
|
98
|
+
<SearchIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
99
|
+
<input
|
|
100
|
+
type="text"
|
|
101
|
+
value={query}
|
|
102
|
+
onChange={(e) => handleQuery(e.target.value)}
|
|
103
|
+
onKeyDown={(e) => {
|
|
104
|
+
if (e.key === "Enter") { e.preventDefault(); addTyped() }
|
|
105
|
+
if (e.key === "Escape") setOpen(false)
|
|
106
|
+
}}
|
|
107
|
+
onFocus={() => results.length > 0 && setOpen(true)}
|
|
108
|
+
placeholder={onSearch ? "Search extensions or type an ID…" : "Type an extension ID…"}
|
|
109
|
+
aria-label="Extension search"
|
|
110
|
+
className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none"
|
|
111
|
+
/>
|
|
112
|
+
{loading && <Loader2Icon className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Results in the flow rather than in a popover: this sits inside a
|
|
116
|
+
section card that clips its overflow, and a floating list gets cut
|
|
117
|
+
at the card's bottom edge. Pushing the form down costs nothing. */}
|
|
118
|
+
{open && (results.length > 0 || loading) && (
|
|
119
|
+
<div className="mt-1 space-y-1 rounded-md border border-border bg-muted/20 p-1">
|
|
120
|
+
{loading && results.length === 0
|
|
121
|
+
? [0, 1, 2].map((i) => <ExtensionCardSkeleton key={i} />)
|
|
122
|
+
: results.map((extension) => {
|
|
123
|
+
const id = extensionId(extension)
|
|
124
|
+
const already = value.includes(id)
|
|
125
|
+
return (
|
|
126
|
+
<ExtensionCard
|
|
127
|
+
key={id}
|
|
128
|
+
extension={extension}
|
|
129
|
+
selected={already}
|
|
130
|
+
disabled={already}
|
|
131
|
+
onSelect={() => add(extension)}
|
|
132
|
+
className="border-transparent bg-card"
|
|
133
|
+
/>
|
|
134
|
+
)
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{value.length > 0 && (
|
|
141
|
+
<div className="space-y-1.5">
|
|
142
|
+
{value.map((id) => (
|
|
143
|
+
<ExtensionCard
|
|
144
|
+
key={id}
|
|
145
|
+
extension={known[id] ?? { id }}
|
|
146
|
+
custom={!known[id] && !id.includes(".")}
|
|
147
|
+
action={
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
aria-label={`Remove ${id}`}
|
|
151
|
+
onClick={() => onChange(value.filter((e) => e !== id))}
|
|
152
|
+
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
|
|
153
|
+
>
|
|
154
|
+
<XIcon className="h-3.5 w-3.5" />
|
|
155
|
+
</button>
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{value.length > 0 && (
|
|
163
|
+
<p className="text-xs text-muted-foreground">
|
|
164
|
+
{value.length} extension{value.length > 1 ? "s" : ""} — installed at worker startup via code-server
|
|
165
|
+
</p>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { PlusIcon, XIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { FeatureCard } from "../devcontainer/feature-card"
|
|
8
|
+
import { FeatureTile } from "../devcontainer/feature-tile"
|
|
9
|
+
import { parseRegistryRef } from "../devcontainer/registry"
|
|
10
|
+
import type { DevcontainerFeatureEntry } from "../devcontainer/types"
|
|
11
|
+
import { Button } from "../button"
|
|
12
|
+
import { Input } from "../input"
|
|
13
|
+
import type { ProjectFeatureSelection } from "./types"
|
|
14
|
+
|
|
15
|
+
export interface FeaturePickerProps {
|
|
16
|
+
features?: DevcontainerFeatureEntry[]
|
|
17
|
+
value: ProjectFeatureSelection[]
|
|
18
|
+
onChange: (value: ProjectFeatureSelection[]) => void
|
|
19
|
+
/**
|
|
20
|
+
* `chips` (default) — the catalog as tag-sized entries that flow and wrap,
|
|
21
|
+
* with the cards of what you picked beside them. `cards` — every entry as a
|
|
22
|
+
* full card, which only stays readable while the catalog is short.
|
|
23
|
+
*/
|
|
24
|
+
layout?: "chips" | "cards"
|
|
25
|
+
/** Where the picked cards go. `auto` splits above ~52rem. */
|
|
26
|
+
detail?: "auto" | "side" | "below"
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pick the devcontainer features: the catalog on one side, what you've composed
|
|
32
|
+
* on the other.
|
|
33
|
+
*
|
|
34
|
+
* Same treatment as `ImagePicker`, with one structural difference — features are
|
|
35
|
+
* **multi-select**, so the panel can't be "the card of the selected one". It's
|
|
36
|
+
* the list of everything picked, in install order, each with its option chips,
|
|
37
|
+
* its author and its OCI reference, and a cross to drop it. Hand-typed refs land
|
|
38
|
+
* in that same list rather than in a second one underneath, because from the
|
|
39
|
+
* user's side they're the same thing: a feature that will be installed.
|
|
40
|
+
*
|
|
41
|
+
* Selection order is preserved — features install in the order they're listed,
|
|
42
|
+
* so a `Set` would quietly lose information here.
|
|
43
|
+
*/
|
|
44
|
+
export function FeaturePicker({
|
|
45
|
+
features = [],
|
|
46
|
+
value,
|
|
47
|
+
onChange,
|
|
48
|
+
layout = "chips",
|
|
49
|
+
detail: detailPosition = "auto",
|
|
50
|
+
className,
|
|
51
|
+
}: FeaturePickerProps) {
|
|
52
|
+
const [customInput, setCustomInput] = useState("")
|
|
53
|
+
|
|
54
|
+
const isSelected = (id?: string) => !!id && value.some((f) => f.id === id)
|
|
55
|
+
const catalogById = new Map(features.filter((f) => f.id).map((f) => [f.id!, f]))
|
|
56
|
+
|
|
57
|
+
function toggle(feature: DevcontainerFeatureEntry) {
|
|
58
|
+
const id = feature.id
|
|
59
|
+
if (!id) return
|
|
60
|
+
onChange(isSelected(id) ? value.filter((f) => f.id !== id) : [...value, { id }])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function addCustom() {
|
|
64
|
+
const ociRef = customInput.trim()
|
|
65
|
+
if (!ociRef || value.some((f) => f.ociRef === ociRef)) return
|
|
66
|
+
// Same id shape the dashboard has always written, so an existing project
|
|
67
|
+
// round-trips unchanged: "ghcr.io/foo/features/mytool:1" → "custom-mytool".
|
|
68
|
+
const id = `custom-${parseRegistryRef(ociRef).name ?? ociRef}`
|
|
69
|
+
onChange([...value, { id, ociRef }])
|
|
70
|
+
setCustomInput("")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const customField = (
|
|
74
|
+
<div className="flex gap-2">
|
|
75
|
+
<Input
|
|
76
|
+
value={customInput}
|
|
77
|
+
aria-label="Custom OCI reference"
|
|
78
|
+
placeholder="Custom OCI ref, e.g. ghcr.io/foo/features/mytool:1"
|
|
79
|
+
className="flex-1"
|
|
80
|
+
onChange={(e) => setCustomInput(e.target.value)}
|
|
81
|
+
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addCustom())}
|
|
82
|
+
/>
|
|
83
|
+
<Button type="button" variant="outline" onClick={addCustom}>
|
|
84
|
+
<PlusIcon className="mr-1 h-3.5 w-3.5" />
|
|
85
|
+
Add
|
|
86
|
+
</Button>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
/** One picked feature — catalog entry or hand-typed ref — with its remove cross. */
|
|
91
|
+
const picked = value.map((selection) => {
|
|
92
|
+
const entry = catalogById.get(selection.id)
|
|
93
|
+
return (
|
|
94
|
+
<FeatureCard
|
|
95
|
+
key={selection.id}
|
|
96
|
+
feature={entry ?? { id: selection.id, ociRef: selection.ociRef }}
|
|
97
|
+
custom={!entry}
|
|
98
|
+
copyable
|
|
99
|
+
options={selection.options}
|
|
100
|
+
action={
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
aria-label={`Remove ${entry?.label ?? selection.ociRef ?? selection.id}`}
|
|
104
|
+
onClick={() => onChange(value.filter((f) => f.id !== selection.id))}
|
|
105
|
+
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
|
|
106
|
+
>
|
|
107
|
+
<XIcon className="h-3.5 w-3.5" />
|
|
108
|
+
</button>
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (layout === "cards") {
|
|
115
|
+
return (
|
|
116
|
+
<div className={cn("@container/picker space-y-2", className)}>
|
|
117
|
+
<div className="grid grid-cols-1 gap-2 @min-[34rem]/picker:grid-cols-2">
|
|
118
|
+
{features.map((feature) => (
|
|
119
|
+
<FeatureCard
|
|
120
|
+
key={feature.id ?? feature.ociRef}
|
|
121
|
+
feature={feature}
|
|
122
|
+
copyable
|
|
123
|
+
options={value.find((f) => f.id === feature.id)?.options}
|
|
124
|
+
selected={isSelected(feature.id)}
|
|
125
|
+
onSelect={() => toggle(feature)}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
{customField}
|
|
130
|
+
{value.length > 0 && <Count n={value.length} />}
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A CONTAINER query, so the split depends on the picker's own width and never
|
|
136
|
+
// on the window's. Stacked is the same reading order, just vertical.
|
|
137
|
+
const split = {
|
|
138
|
+
auto: "@min-[52rem]/picker:grid-cols-[1fr_minmax(18rem,22rem)] @min-[52rem]/picker:items-start",
|
|
139
|
+
// Even "forced" has a floor: below ~34rem two columns simply don't fit, and
|
|
140
|
+
// the panel would push the page into horizontal scrolling. `side` means "as
|
|
141
|
+
// soon as it fits", not "always".
|
|
142
|
+
side: "@min-[34rem]/picker:grid-cols-[1fr_minmax(15rem,22rem)] @min-[34rem]/picker:items-start",
|
|
143
|
+
below: "",
|
|
144
|
+
}[detailPosition]
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className={cn("@container/picker", className)}>
|
|
148
|
+
<div className={cn("grid gap-3", split)}>
|
|
149
|
+
<div className="space-y-3">
|
|
150
|
+
<div className="flex flex-wrap content-start gap-1.5">
|
|
151
|
+
{features.map((feature) => (
|
|
152
|
+
<FeatureTile
|
|
153
|
+
key={feature.id ?? feature.ociRef}
|
|
154
|
+
feature={feature}
|
|
155
|
+
selected={isSelected(feature.id)}
|
|
156
|
+
onSelect={() => toggle(feature)}
|
|
157
|
+
/>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
{customField}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="space-y-1.5">
|
|
164
|
+
{picked.length > 0 ? (
|
|
165
|
+
<>
|
|
166
|
+
{picked}
|
|
167
|
+
<Count n={value.length} />
|
|
168
|
+
</>
|
|
169
|
+
) : (
|
|
170
|
+
// A placeholder rather than nothing: without it the whole section
|
|
171
|
+
// reflows on the first pick and jumps under the cursor.
|
|
172
|
+
<div
|
|
173
|
+
className={cn(
|
|
174
|
+
"min-h-[7.5rem] items-center justify-center rounded-lg border border-dashed border-border p-4 text-center text-xs text-muted-foreground",
|
|
175
|
+
detailPosition === "side" ? "hidden @min-[34rem]/picker:flex" : "hidden @min-[52rem]/picker:flex"
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
Nothing selected — the base image ships as-is.
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function Count({ n }: { n: number }) {
|
|
188
|
+
return (
|
|
189
|
+
<p className="text-xs text-muted-foreground">
|
|
190
|
+
{n} feature{n > 1 ? "s" : ""} selected — installed at worker startup
|
|
191
|
+
</p>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../utils"
|
|
4
|
+
import { Label } from "../label"
|
|
5
|
+
|
|
6
|
+
/** The four warm accents; a section rotates through them down the form. */
|
|
7
|
+
export type SectionAccent = "build" | "ship" | "run" | "flame"
|
|
8
|
+
|
|
9
|
+
export interface FormSectionProps {
|
|
10
|
+
/** Step number in the badge. Omitted → no badge. */
|
|
11
|
+
n?: number
|
|
12
|
+
icon?: ComponentType<{ className?: string }>
|
|
13
|
+
accent?: SectionAccent
|
|
14
|
+
title: string
|
|
15
|
+
hint?: string
|
|
16
|
+
children: ReactNode
|
|
17
|
+
/** Mostly for the entrance `animationDelay` a stack of sections staggers with. */
|
|
18
|
+
style?: React.CSSProperties
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One numbered step of a long form: an accent bar, a number, a title, a hint.
|
|
24
|
+
*
|
|
25
|
+
* Exported on its own because a form this long is really a *stack of sections*,
|
|
26
|
+
* and an app that adds one of its own (a deploy key, a billing block) should get
|
|
27
|
+
* the same object rather than approximate it.
|
|
28
|
+
*
|
|
29
|
+
* The entrance animation the dashboard used to do with framer-motion is a plain
|
|
30
|
+
* CSS keyframe here — the package can't take a motion library as a dependency
|
|
31
|
+
* for one fade-in, and `motion-reduce:animate-none` covers the accessibility
|
|
32
|
+
* side that `useReducedMotion` covered before.
|
|
33
|
+
*/
|
|
34
|
+
export function FormSection({ n, icon: Icon, accent = "build", title, hint, children, style, className }: FormSectionProps) {
|
|
35
|
+
return (
|
|
36
|
+
<section
|
|
37
|
+
data-slot="form-section"
|
|
38
|
+
// `--a` set once here, then read by the bar, the badge and the icon: the
|
|
39
|
+
// accent is one custom property, not four Tailwind variants to keep in sync.
|
|
40
|
+
style={{ "--a": `var(--${accent})`, ...style } as React.CSSProperties}
|
|
41
|
+
className={cn(
|
|
42
|
+
// `p-4` on a phone: 20px of padding each side eats a tenth of a 390px
|
|
43
|
+
// screen twice over. The container is the form, so this follows the
|
|
44
|
+
// form's width and not the window's.
|
|
45
|
+
"relative overflow-hidden rounded-xl border border-border bg-card p-4 @min-[30rem]/form:p-5",
|
|
46
|
+
"motion-safe:animate-[form-section-in_0.5s_cubic-bezier(0.22,1,0.36,1)_both]",
|
|
47
|
+
className
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<span aria-hidden className="absolute inset-y-0 left-0 w-0.5 bg-[var(--a)]/40" />
|
|
51
|
+
<div className="mb-4 flex items-center gap-3">
|
|
52
|
+
{n != null && (
|
|
53
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--a)]/[0.12] font-display text-sm font-bold text-[var(--a)]">
|
|
54
|
+
{n}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
<div className="min-w-0">
|
|
58
|
+
<h3 className="flex items-center gap-2 font-display text-base font-bold tracking-tight">
|
|
59
|
+
{Icon && <Icon className="h-4 w-4 shrink-0 text-[var(--a)]" />}
|
|
60
|
+
{title}
|
|
61
|
+
</h3>
|
|
62
|
+
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
{children}
|
|
66
|
+
</section>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Label + control + help line — the unit every section is built out of. */
|
|
71
|
+
export function Field({
|
|
72
|
+
label,
|
|
73
|
+
htmlFor,
|
|
74
|
+
hint,
|
|
75
|
+
error,
|
|
76
|
+
children,
|
|
77
|
+
className,
|
|
78
|
+
}: {
|
|
79
|
+
label?: ReactNode
|
|
80
|
+
htmlFor?: string
|
|
81
|
+
hint?: ReactNode
|
|
82
|
+
error?: ReactNode
|
|
83
|
+
children: ReactNode
|
|
84
|
+
className?: string
|
|
85
|
+
}) {
|
|
86
|
+
return (
|
|
87
|
+
<div className={cn("space-y-1.5", className)}>
|
|
88
|
+
{label && <Label htmlFor={htmlFor}>{label}</Label>}
|
|
89
|
+
{children}
|
|
90
|
+
{error ? (
|
|
91
|
+
<p className="text-xs text-destructive">{error}</p>
|
|
92
|
+
) : hint ? (
|
|
93
|
+
<p className="text-xs text-muted-foreground">{hint}</p>
|
|
94
|
+
) : null}
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|