@spunto/design-system 0.10.0 → 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.
Files changed (28) hide show
  1. package/README.md +85 -3
  2. package/package.json +9 -1
  3. package/src/components/devcontainer/catalog-card.tsx +269 -0
  4. package/src/components/devcontainer/catalog-tile.tsx +133 -0
  5. package/src/components/devcontainer/extension-card.tsx +146 -0
  6. package/src/components/devcontainer/feature-card.tsx +157 -0
  7. package/src/components/devcontainer/feature-tile.tsx +63 -0
  8. package/src/components/devcontainer/image-card.tsx +107 -0
  9. package/src/components/devcontainer/image-tile.tsx +56 -0
  10. package/src/components/devcontainer/index.ts +68 -0
  11. package/src/components/devcontainer/publisher-badge.tsx +91 -0
  12. package/src/components/devcontainer/registry-ref.tsx +89 -0
  13. package/src/components/devcontainer/registry.ts +154 -0
  14. package/src/components/devcontainer/stack-mark-paths.ts +60 -0
  15. package/src/components/devcontainer/stack-mark.tsx +92 -0
  16. package/src/components/devcontainer/types.ts +84 -0
  17. package/src/components/projects/advanced-sections.tsx +136 -0
  18. package/src/components/projects/build-manifest.tsx +116 -0
  19. package/src/components/projects/extension-picker.tsx +169 -0
  20. package/src/components/projects/feature-picker.tsx +193 -0
  21. package/src/components/projects/form-section.tsx +97 -0
  22. package/src/components/projects/image-picker.tsx +206 -0
  23. package/src/components/projects/index.ts +58 -0
  24. package/src/components/projects/project-form.tsx +488 -0
  25. package/src/components/projects/repo-list.tsx +302 -0
  26. package/src/components/projects/secret-list.tsx +159 -0
  27. package/src/components/projects/types.ts +136 -0
  28. package/styles.css +13 -0
@@ -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"