@spunto/design-system 0.9.3 → 0.11.0
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 +102 -4
- package/package.json +9 -1
- package/src/components/command-palette.tsx +702 -0
- 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/kbd.tsx +117 -0
- package/src/components/projects/advanced-sections.tsx +136 -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 +488 -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/src/index.ts +35 -0
- package/styles.css +13 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils"
|
|
6
|
+
import { ImageCard } from "../devcontainer/image-card"
|
|
7
|
+
import { ImageTile } from "../devcontainer/image-tile"
|
|
8
|
+
import type { DevcontainerImageEntry } from "../devcontainer/types"
|
|
9
|
+
import { Input } from "../input"
|
|
10
|
+
|
|
11
|
+
export interface ImagePickerProps {
|
|
12
|
+
images?: DevcontainerImageEntry[]
|
|
13
|
+
/** The image reference itself — not a catalog id. Empty = nothing picked yet. */
|
|
14
|
+
value: string
|
|
15
|
+
onChange: (image: string) => void
|
|
16
|
+
/**
|
|
17
|
+
* `chips` (default) — tag-sized entries flowing and wrapping on the left, the
|
|
18
|
+
* full card of the selected one on the right. `tiles` — a square-tile grid
|
|
19
|
+
* with the detail card underneath. `cards` — every entry as a full card, which
|
|
20
|
+
* only stays readable while the catalog is short.
|
|
21
|
+
*/
|
|
22
|
+
layout?: "chips" | "tiles" | "cards"
|
|
23
|
+
/**
|
|
24
|
+
* Where the detail card goes in `chips` layout. `auto` (default) puts it on
|
|
25
|
+
* the right once the picker is wide enough for both, and underneath when it
|
|
26
|
+
* isn't — below roughly 52rem the chip column gets down to two chips a row,
|
|
27
|
+
* at which point the list is taller than it is scannable and stacking wins.
|
|
28
|
+
* `side` / `below` force it, for a layout that already knows its own width.
|
|
29
|
+
*/
|
|
30
|
+
detail?: "auto" | "side" | "below"
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pick the base image: the catalog, plus a "custom image" entry that swaps in a
|
|
36
|
+
* free-text field.
|
|
37
|
+
*
|
|
38
|
+
* **Why chips + a detail panel.** Full cards are the right object for six
|
|
39
|
+
* entries. Past a dozen they're a wall of near-identical blocks all repeating
|
|
40
|
+
* `mcr.microsoft.com/devcontainers/`, and picking a runtime is a *logo* task,
|
|
41
|
+
* not a reading one. Chips flow like tags — thirty images take the room six
|
|
42
|
+
* cards used to — and everything a chip has to drop (publisher, description, the
|
|
43
|
+
* full reference) comes back in one big card, for the single entry you actually
|
|
44
|
+
* chose. Scanning and reading stop competing for the same space.
|
|
45
|
+
*
|
|
46
|
+
* **The value is the reference, never the catalog id.** A ref that isn't in the
|
|
47
|
+
* catalog (typed by hand, or from a catalog that has since changed) is a
|
|
48
|
+
* first-class state, not a bug: it selects the custom entry and shows itself in
|
|
49
|
+
* the field.
|
|
50
|
+
*/
|
|
51
|
+
export function ImagePicker({
|
|
52
|
+
images = [],
|
|
53
|
+
value,
|
|
54
|
+
onChange,
|
|
55
|
+
layout = "chips",
|
|
56
|
+
detail: detailPosition = "auto",
|
|
57
|
+
className,
|
|
58
|
+
}: ImagePickerProps) {
|
|
59
|
+
const matched = images.find((image) => image.image === value)
|
|
60
|
+
// An unrecognised ref means custom — but so does an empty value the user has
|
|
61
|
+
// just switched to, and the two are indistinguishable from `value` alone.
|
|
62
|
+
// Without this flag, clearing the field would bounce the selection back to a
|
|
63
|
+
// catalog entry mid-typing.
|
|
64
|
+
const [customPinned, setCustomPinned] = useState(false)
|
|
65
|
+
const isCustom = customPinned || (!!value && !matched)
|
|
66
|
+
|
|
67
|
+
const customEntry: DevcontainerImageEntry = {
|
|
68
|
+
label: "Custom image",
|
|
69
|
+
description: "Enter any devcontainer-compatible image",
|
|
70
|
+
image: isCustom ? value : undefined,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function selectCatalog(image: DevcontainerImageEntry) {
|
|
74
|
+
setCustomPinned(false)
|
|
75
|
+
onChange(image.image ?? "")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function selectCustom() {
|
|
79
|
+
setCustomPinned(true)
|
|
80
|
+
if (matched) onChange("")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isSelected = (image: DevcontainerImageEntry) => !isCustom && image.image === value
|
|
84
|
+
// Only worth a card once there's something to detail: custom-but-empty has
|
|
85
|
+
// nothing to show yet, and an empty card reads as a broken one.
|
|
86
|
+
const detail = matched ?? (isCustom && value.trim() ? { label: "Custom image", image: value } : null)
|
|
87
|
+
|
|
88
|
+
const customInput = isCustom ? <CustomInput value={value} onChange={onChange} /> : null
|
|
89
|
+
|
|
90
|
+
if (layout === "cards") {
|
|
91
|
+
return (
|
|
92
|
+
<div className={cn("@container/picker space-y-3", className)}>
|
|
93
|
+
<div className="grid grid-cols-1 gap-2 @min-[34rem]/picker:grid-cols-2">
|
|
94
|
+
{images.map((image) => (
|
|
95
|
+
<ImageCard
|
|
96
|
+
key={image.id ?? image.image}
|
|
97
|
+
image={image}
|
|
98
|
+
copyable
|
|
99
|
+
selected={isSelected(image)}
|
|
100
|
+
onSelect={() => selectCatalog(image)}
|
|
101
|
+
/>
|
|
102
|
+
))}
|
|
103
|
+
<ImageCard image={customEntry} custom selected={isCustom} onSelect={selectCustom} />
|
|
104
|
+
</div>
|
|
105
|
+
{customInput}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (layout === "tiles") {
|
|
111
|
+
return (
|
|
112
|
+
<div className={cn("space-y-3", className)}>
|
|
113
|
+
{/* `auto-fill` + a min track, not a fixed column count: the same grid is
|
|
114
|
+
four tiles wide in a form column and eight on a full-width page. */}
|
|
115
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(84px,1fr))] gap-2">
|
|
116
|
+
{images.map((image) => (
|
|
117
|
+
<ImageTile
|
|
118
|
+
key={image.id ?? image.image}
|
|
119
|
+
image={image}
|
|
120
|
+
variant="tile"
|
|
121
|
+
selected={isSelected(image)}
|
|
122
|
+
onSelect={() => selectCatalog(image)}
|
|
123
|
+
/>
|
|
124
|
+
))}
|
|
125
|
+
<ImageTile image={customEntry} variant="tile" custom selected={isCustom} onSelect={selectCustom} />
|
|
126
|
+
</div>
|
|
127
|
+
{customInput}
|
|
128
|
+
{detail && <Detail image={detail} custom={isCustom} />}
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A CONTAINER query, so the split depends on the picker's own width and never
|
|
134
|
+
// on the window's. Stacked is the same reading order, just vertical.
|
|
135
|
+
const split = {
|
|
136
|
+
auto: "@min-[52rem]/picker:grid-cols-[1fr_minmax(18rem,22rem)] @min-[52rem]/picker:items-start",
|
|
137
|
+
// Even "forced" has a floor: below ~34rem two columns simply don't fit, and
|
|
138
|
+
// the panel would push the page into horizontal scrolling. `side` means "as
|
|
139
|
+
// soon as it fits", not "always".
|
|
140
|
+
side: "@min-[34rem]/picker:grid-cols-[1fr_minmax(15rem,22rem)] @min-[34rem]/picker:items-start",
|
|
141
|
+
below: "",
|
|
142
|
+
}[detailPosition]
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className={cn("@container/picker", className)}>
|
|
146
|
+
<div className={cn("grid gap-3", split)}>
|
|
147
|
+
<div className="space-y-3">
|
|
148
|
+
<div className="flex flex-wrap content-start gap-1.5">
|
|
149
|
+
{images.map((image) => (
|
|
150
|
+
<ImageTile
|
|
151
|
+
key={image.id ?? image.image}
|
|
152
|
+
image={image}
|
|
153
|
+
selected={isSelected(image)}
|
|
154
|
+
onSelect={() => selectCatalog(image)}
|
|
155
|
+
/>
|
|
156
|
+
))}
|
|
157
|
+
<ImageTile image={customEntry} custom selected={isCustom} onSelect={selectCustom} />
|
|
158
|
+
</div>
|
|
159
|
+
{customInput}
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
{detail ? (
|
|
163
|
+
<Detail image={detail} custom={isCustom} />
|
|
164
|
+
) : (
|
|
165
|
+
// A placeholder rather than nothing: without it the chips reflow the
|
|
166
|
+
// moment you pick one, and the whole section jumps under the cursor.
|
|
167
|
+
<div
|
|
168
|
+
className={cn(
|
|
169
|
+
"min-h-[7.5rem] items-center justify-center rounded-lg border border-dashed border-border p-4 text-center text-xs text-muted-foreground",
|
|
170
|
+
detailPosition === "side" ? "hidden @min-[34rem]/picker:flex" : "hidden @min-[52rem]/picker:flex"
|
|
171
|
+
)}
|
|
172
|
+
>
|
|
173
|
+
Pick an image to see what it ships.
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function Detail({ image, custom }: { image: DevcontainerImageEntry; custom: boolean }) {
|
|
182
|
+
return (
|
|
183
|
+
<ImageCard
|
|
184
|
+
// Keyed on the reference so switching images replays the entrance — the
|
|
185
|
+
// panel is the answer to a click, and it should read as having changed.
|
|
186
|
+
key={image.image}
|
|
187
|
+
image={image}
|
|
188
|
+
custom={custom}
|
|
189
|
+
copyable
|
|
190
|
+
className="motion-safe:animate-[form-section-in_0.35s_cubic-bezier(0.22,1,0.36,1)_both]"
|
|
191
|
+
/>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function CustomInput({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
|
196
|
+
return (
|
|
197
|
+
<Input
|
|
198
|
+
aria-label="Custom image reference"
|
|
199
|
+
placeholder="mcr.microsoft.com/devcontainers/typescript-node:20"
|
|
200
|
+
value={value}
|
|
201
|
+
required
|
|
202
|
+
className="font-mono text-xs"
|
|
203
|
+
onChange={(e) => onChange(e.target.value)}
|
|
204
|
+
/>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @spunto/design-system/projects — composing a project.
|
|
2
|
+
//
|
|
3
|
+
// Third domain sub-export, after `/workers` and `/devcontainer`. `ProjectForm`
|
|
4
|
+
// is the whole "compose a dev environment" screen: identity, base image, repos,
|
|
5
|
+
// features, extensions, lifecycle, ports, prewarm, docker-in-docker, secrets,
|
|
6
|
+
// plus the live build manifest that recaps it and carries the submit.
|
|
7
|
+
//
|
|
8
|
+
// Controlled by a single `ProjectFormValue`, and nothing in here fetches:
|
|
9
|
+
// catalogs come in as data, the extension search is a callback, the GitHub
|
|
10
|
+
// combobox is a slot, `onSubmit` hands the value back. Which sections show is a
|
|
11
|
+
// prop — creation and editing are one component with a different list.
|
|
12
|
+
//
|
|
13
|
+
// Every brick is exported on its own, because a screen that needs only the repo
|
|
14
|
+
// rows or only the manifest shouldn't have to take the form with it.
|
|
15
|
+
|
|
16
|
+
export { ProjectForm, manifestRows } from "./project-form"
|
|
17
|
+
export type { ProjectFormProps } from "./project-form"
|
|
18
|
+
|
|
19
|
+
export { FormSection, Field } from "./form-section"
|
|
20
|
+
export type { FormSectionProps, SectionAccent } from "./form-section"
|
|
21
|
+
|
|
22
|
+
export { AdvancedDisclosure, advancedSummary } from "./advanced-sections"
|
|
23
|
+
export type { AdvancedDisclosureProps } from "./advanced-sections"
|
|
24
|
+
|
|
25
|
+
export { BuildManifest } from "./build-manifest"
|
|
26
|
+
export type { BuildManifestProps, ManifestRow } from "./build-manifest"
|
|
27
|
+
|
|
28
|
+
export { RepoList, deriveFromGitUrl } from "./repo-list"
|
|
29
|
+
export type { RepoListProps, GithubRepoOption, GithubConnection, RepoFieldRender } from "./repo-list"
|
|
30
|
+
|
|
31
|
+
export { SecretList } from "./secret-list"
|
|
32
|
+
export type { SecretListProps } from "./secret-list"
|
|
33
|
+
|
|
34
|
+
export { ImagePicker } from "./image-picker"
|
|
35
|
+
export type { ImagePickerProps } from "./image-picker"
|
|
36
|
+
|
|
37
|
+
export { FeaturePicker } from "./feature-picker"
|
|
38
|
+
export type { FeaturePickerProps } from "./feature-picker"
|
|
39
|
+
|
|
40
|
+
export { ExtensionPicker } from "./extension-picker"
|
|
41
|
+
export type { ExtensionPickerProps } from "./extension-picker"
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
CREATE_SECTIONS,
|
|
45
|
+
EDIT_SECTIONS,
|
|
46
|
+
emptyProjectFormValue,
|
|
47
|
+
toProjectFormValue,
|
|
48
|
+
} from "./types"
|
|
49
|
+
export type {
|
|
50
|
+
ProjectFeatureSelection,
|
|
51
|
+
ProjectFormSectionId,
|
|
52
|
+
ProjectFormValue,
|
|
53
|
+
ProjectRepo,
|
|
54
|
+
ProjectSecretDraft,
|
|
55
|
+
ProjectSecretRef,
|
|
56
|
+
} from "./types"
|
|
57
|
+
|
|
58
|
+
export { cn } from "../../utils"
|