@gogitcms/design-system 0.15.0-next.3

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 (70) hide show
  1. package/README.md +77 -0
  2. package/css/components.css +312 -0
  3. package/css/tokens.css +212 -0
  4. package/package.json +65 -0
  5. package/src/ThemeProvider.tsx +86 -0
  6. package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  7. package/src/__tests__/BranchImport.test.tsx +46 -0
  8. package/src/__tests__/Button.test.tsx +45 -0
  9. package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  11. package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  12. package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  13. package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  14. package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  15. package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  16. package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  17. package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  18. package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  19. package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  20. package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  21. package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  22. package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  23. package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  24. package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  25. package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  26. package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  27. package/src/__tests__/MediaBrowser.test.tsx +353 -0
  28. package/src/__tests__/MediaField.test.tsx +185 -0
  29. package/src/__tests__/Notifications.test.tsx +69 -0
  30. package/src/__tests__/Onboarding.test.tsx +287 -0
  31. package/src/__tests__/cssTokens.test.ts +201 -0
  32. package/src/__tests__/fieldComponents.test.ts +43 -0
  33. package/src/__tests__/reorder.test.ts +58 -0
  34. package/src/components/ApplyChangesModal.tsx +348 -0
  35. package/src/components/BranchImport.tsx +157 -0
  36. package/src/components/BranchMenu.tsx +192 -0
  37. package/src/components/Button.tsx +131 -0
  38. package/src/components/ChangeDetail.tsx +472 -0
  39. package/src/components/ChangeRequestSummary.tsx +173 -0
  40. package/src/components/CollabField.tsx +388 -0
  41. package/src/components/ContentBrowser.tsx +5073 -0
  42. package/src/components/Icon.tsx +28 -0
  43. package/src/components/Icon.web.tsx +31 -0
  44. package/src/components/Input.tsx +106 -0
  45. package/src/components/MediaBrowser.tsx +766 -0
  46. package/src/components/MediaField.tsx +670 -0
  47. package/src/components/MediaPreview.tsx +91 -0
  48. package/src/components/MediaPreview.web.tsx +169 -0
  49. package/src/components/NavRow.tsx +105 -0
  50. package/src/components/Notifications.tsx +301 -0
  51. package/src/components/Onboarding.tsx +751 -0
  52. package/src/components/ProjectMenu.tsx +124 -0
  53. package/src/components/Segment.tsx +87 -0
  54. package/src/components/Skeleton.tsx +216 -0
  55. package/src/components/Spinner.tsx +44 -0
  56. package/src/components/Text.tsx +85 -0
  57. package/src/components/documentDrafts.ts +213 -0
  58. package/src/components/layout.tsx +284 -0
  59. package/src/components/primitives.tsx +143 -0
  60. package/src/components/reorder.ts +40 -0
  61. package/src/fieldComponents.ts +63 -0
  62. package/src/icons.ts +102 -0
  63. package/src/index.ts +198 -0
  64. package/src/media.ts +229 -0
  65. package/src/theme.ts +116 -0
  66. package/src/web/Button.tsx +110 -0
  67. package/src/web/Icon.tsx +52 -0
  68. package/src/web/Input.tsx +39 -0
  69. package/src/web/index.ts +43 -0
  70. package/src/web/primitives.tsx +119 -0
@@ -0,0 +1,287 @@
1
+ import React from "react";
2
+ import { Pressable, View } from "react-native";
3
+ import { render, fireEvent, screen, waitFor, act } from "@testing-library/react";
4
+ import { ThemeProvider } from "../ThemeProvider";
5
+ import { Text } from "../components/Text";
6
+ import {
7
+ OnboardingProvider,
8
+ placeCard,
9
+ useOnboarding,
10
+ useOnboardingTarget,
11
+ type OnboardingFlow,
12
+ type OnboardingProgress,
13
+ } from "../components/Onboarding";
14
+
15
+ const flow: OnboardingFlow = {
16
+ id: "dashboard",
17
+ version: 1,
18
+ title: "Dashboard tour",
19
+ steps: [
20
+ { id: "welcome", title: "Welcome", body: "This is the overview." },
21
+ { id: "stats", title: "Your numbers", body: "What is connected.", target: "stats" },
22
+ { id: "team", title: "Your team", body: "Who can edit.", target: "team" },
23
+ ],
24
+ };
25
+
26
+ function Screen() {
27
+ const stats = useOnboardingTarget("stats");
28
+ const team = useOnboardingTarget("team");
29
+ const { restart, statusFor } = useOnboarding();
30
+ return (
31
+ <View>
32
+ <View ref={stats.ref as any} testID="stats-panel" />
33
+ <View ref={team.ref as any} testID="team-panel" />
34
+ <Text testID="dash-status">{statusFor("dashboard")}</Text>
35
+ <Pressable testID="replay" onPress={() => restart("dashboard")} />
36
+ </View>
37
+ );
38
+ }
39
+
40
+ function wrap(props: Partial<React.ComponentProps<typeof OnboardingProvider>> = {}) {
41
+ return (
42
+ <ThemeProvider>
43
+ <OnboardingProvider flows={[flow]} activeFlow="dashboard" progress={{}} {...props}>
44
+ <Screen />
45
+ </OnboardingProvider>
46
+ </ThemeProvider>
47
+ );
48
+ }
49
+
50
+ test("a flow nobody has seen starts itself and shows the first step", async () => {
51
+ render(wrap());
52
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toBeInTheDocument());
53
+ expect(screen.getByTestId("onboarding-card")).toHaveTextContent("Welcome");
54
+ expect(screen.getByTestId("onboarding-card")).toHaveTextContent("Dashboard tour · 1/3");
55
+ });
56
+
57
+ test("progress that has not loaded yet suppresses auto-start", () => {
58
+ // `undefined` is "still fetching" — replaying a finished tour because the
59
+ // round-trip was slow is the failure this guards.
60
+ render(wrap({ progress: undefined }));
61
+ expect(screen.queryByTestId("onboarding-card")).not.toBeInTheDocument();
62
+ });
63
+
64
+ test("a completed flow at the current version does not run again", () => {
65
+ const progress: Record<string, OnboardingProgress> = {
66
+ dashboard: { status: "completed", stepIndex: 2, version: 1 },
67
+ };
68
+ render(wrap({ progress }));
69
+ expect(screen.queryByTestId("onboarding-card")).not.toBeInTheDocument();
70
+ });
71
+
72
+ test("a bumped flow version re-offers a completed tour", async () => {
73
+ const progress: Record<string, OnboardingProgress> = {
74
+ dashboard: { status: "completed", stepIndex: 2, version: 1 },
75
+ };
76
+ render(wrap({ flows: [{ ...flow, version: 2 }], progress }));
77
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toBeInTheDocument());
78
+ });
79
+
80
+ test("an in-progress flow resumes where it stopped", async () => {
81
+ const progress: Record<string, OnboardingProgress> = {
82
+ dashboard: { status: "in_progress", stepIndex: 1, version: 1 },
83
+ };
84
+ render(wrap({ progress }));
85
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toHaveTextContent("Your numbers"));
86
+ });
87
+
88
+ test("ready=false holds the tour until the screen's data has landed", async () => {
89
+ const { rerender } = render(wrap({ ready: false }));
90
+ expect(screen.queryByTestId("onboarding-card")).not.toBeInTheDocument();
91
+ rerender(wrap({ ready: true }));
92
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toBeInTheDocument());
93
+ });
94
+
95
+ test("enabled=false turns the framework off entirely", () => {
96
+ render(wrap({ enabled: false }));
97
+ expect(screen.queryByTestId("onboarding-overlay")).not.toBeInTheDocument();
98
+ });
99
+
100
+ test("next and back walk the steps and record every position", async () => {
101
+ const onProgress = jest.fn();
102
+ render(wrap({ onProgress }));
103
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toBeInTheDocument());
104
+ expect(onProgress).toHaveBeenLastCalledWith("dashboard", {
105
+ status: "in_progress",
106
+ stepIndex: 0,
107
+ version: 1,
108
+ });
109
+
110
+ // The first step has nowhere to go back to, so it offers Skip instead.
111
+ expect(screen.queryByTestId("onboarding-back")).not.toBeInTheDocument();
112
+
113
+ fireEvent.click(screen.getByTestId("onboarding-next"));
114
+ expect(screen.getByTestId("onboarding-card")).toHaveTextContent("Your numbers");
115
+ expect(onProgress).toHaveBeenLastCalledWith("dashboard", {
116
+ status: "in_progress",
117
+ stepIndex: 1,
118
+ version: 1,
119
+ });
120
+
121
+ fireEvent.click(screen.getByTestId("onboarding-back"));
122
+ expect(screen.getByTestId("onboarding-card")).toHaveTextContent("Welcome");
123
+ expect(onProgress).toHaveBeenLastCalledWith("dashboard", {
124
+ status: "in_progress",
125
+ stepIndex: 0,
126
+ version: 1,
127
+ });
128
+ });
129
+
130
+ test("finishing the last step records completion and closes the tour", async () => {
131
+ const onProgress = jest.fn();
132
+ render(wrap({ onProgress }));
133
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toBeInTheDocument());
134
+
135
+ fireEvent.click(screen.getByTestId("onboarding-next"));
136
+ fireEvent.click(screen.getByTestId("onboarding-next"));
137
+ expect(screen.getByTestId("onboarding-card")).toHaveTextContent("Your team");
138
+
139
+ fireEvent.click(screen.getByTestId("onboarding-next"));
140
+ expect(onProgress).toHaveBeenLastCalledWith("dashboard", {
141
+ status: "completed",
142
+ stepIndex: 2,
143
+ version: 1,
144
+ });
145
+ expect(screen.queryByTestId("onboarding-card")).not.toBeInTheDocument();
146
+ expect(screen.getByTestId("dash-status")).toHaveTextContent("completed");
147
+ });
148
+
149
+ test("skipping records where the user gave up, and does not re-run", async () => {
150
+ const onProgress = jest.fn();
151
+ render(wrap({ onProgress }));
152
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toBeInTheDocument());
153
+ fireEvent.click(screen.getByTestId("onboarding-next"));
154
+ fireEvent.click(screen.getByTestId("onboarding-close"));
155
+
156
+ expect(onProgress).toHaveBeenLastCalledWith("dashboard", {
157
+ status: "skipped",
158
+ stepIndex: 1,
159
+ version: 1,
160
+ });
161
+ // The optimistic local record is what stops the auto-start effect from
162
+ // immediately restarting the tour the user just dismissed.
163
+ expect(screen.queryByTestId("onboarding-card")).not.toBeInTheDocument();
164
+ });
165
+
166
+ test("a host can replay a finished tour from the top", async () => {
167
+ const progress: Record<string, OnboardingProgress> = {
168
+ dashboard: { status: "completed", stepIndex: 2, version: 1 },
169
+ };
170
+ render(wrap({ progress }));
171
+ expect(screen.queryByTestId("onboarding-card")).not.toBeInTheDocument();
172
+
173
+ fireEvent.click(screen.getByTestId("replay"));
174
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toHaveTextContent("Welcome"));
175
+ });
176
+
177
+ test("leaving the screen ends the tour without recording a dismissal", async () => {
178
+ const onProgress = jest.fn();
179
+ const { rerender } = render(wrap({ onProgress }));
180
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toBeInTheDocument());
181
+ onProgress.mockClear();
182
+
183
+ rerender(wrap({ onProgress, activeFlow: "team" }));
184
+ expect(screen.queryByTestId("onboarding-card")).not.toBeInTheDocument();
185
+ expect(onProgress).not.toHaveBeenCalled();
186
+ });
187
+
188
+ test("Escape skips and the arrow keys step through", async () => {
189
+ const onProgress = jest.fn();
190
+ render(wrap({ onProgress }));
191
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toBeInTheDocument());
192
+
193
+ fireEvent.keyDown(document, { key: "ArrowRight" });
194
+ expect(screen.getByTestId("onboarding-card")).toHaveTextContent("Your numbers");
195
+ fireEvent.keyDown(document, { key: "ArrowLeft" });
196
+ expect(screen.getByTestId("onboarding-card")).toHaveTextContent("Welcome");
197
+
198
+ fireEvent.keyDown(document, { key: "Escape" });
199
+ expect(screen.queryByTestId("onboarding-card")).not.toBeInTheDocument();
200
+ expect(onProgress).toHaveBeenLastCalledWith("dashboard", {
201
+ status: "skipped",
202
+ stepIndex: 0,
203
+ version: 1,
204
+ });
205
+ });
206
+
207
+ describe("the spotlight", () => {
208
+ const original = Element.prototype.getBoundingClientRect;
209
+ beforeEach(() => {
210
+ // jsdom lays nothing out, so every rect is zero and every step would fall
211
+ // back to a centered card. Stubbing one gives the anchored path something
212
+ // to measure.
213
+ Element.prototype.getBoundingClientRect = function () {
214
+ return { x: 100, y: 200, left: 100, top: 200, width: 300, height: 80, right: 400, bottom: 280, toJSON() {} } as DOMRect;
215
+ };
216
+ });
217
+ afterEach(() => {
218
+ Element.prototype.getBoundingClientRect = original;
219
+ });
220
+
221
+ test("a step with a registered target cuts a hole around it", async () => {
222
+ render(wrap({ progress: { dashboard: { status: "in_progress", stepIndex: 1, version: 1 } } }));
223
+ await waitFor(() => expect(screen.getByTestId("onboarding-spotlight")).toBeInTheDocument());
224
+ });
225
+
226
+ test("the overlay does not swallow clicks meant for the spotlit control", async () => {
227
+ // The overlay spans the viewport. If it were hit-testable it would eat the
228
+ // click on the very control the step points at, and the spotlight would be a
229
+ // picture of an interactive element rather than the element.
230
+ // The container is transparent to clicks; the pieces that need them (the
231
+ // scrim panels, the card) opt back in individually.
232
+ render(wrap({ progress: { dashboard: { status: "in_progress", stepIndex: 1, version: 1 } } }));
233
+ await waitFor(() => expect(screen.getByTestId("onboarding-overlay")).toBeInTheDocument());
234
+ expect(screen.getByTestId("onboarding-overlay")).toHaveStyle({ pointerEvents: "none" });
235
+ expect(screen.getByTestId("onboarding-card")).toHaveStyle({ pointerEvents: "auto" });
236
+ // The ring is drawn over the anchor, so it has to be transparent to clicks too.
237
+ await waitFor(() =>
238
+ expect(screen.getByTestId("onboarding-spotlight")).toHaveStyle({ pointerEvents: "none" }),
239
+ );
240
+ });
241
+
242
+ test("a step whose target never mounts still shows, unanchored", async () => {
243
+ const orphan: OnboardingFlow = {
244
+ ...flow,
245
+ steps: [{ id: "gone", title: "Missing", body: "No anchor here.", target: "not-rendered" }],
246
+ };
247
+ render(wrap({ flows: [orphan] }));
248
+ await waitFor(() => expect(screen.getByTestId("onboarding-card")).toHaveTextContent("Missing"));
249
+ expect(screen.queryByTestId("onboarding-spotlight")).not.toBeInTheDocument();
250
+ });
251
+ });
252
+
253
+ describe("placeCard", () => {
254
+ const view = { vw: 1000, vh: 800, cardW: 340, placement: "auto" as const };
255
+ const hole = { left: 300, top: 100, width: 200, height: 60 };
256
+
257
+ test("prefers below the spotlight", () => {
258
+ expect(placeCard(hole, { w: 340, h: 180 }, view)).toEqual({ left: 230, top: 172 });
259
+ });
260
+
261
+ test("falls above when there is no room below", () => {
262
+ const low = { ...hole, top: 700 };
263
+ // 700 (anchor top) - 12 (gap) - 180 (card height)
264
+ expect(placeCard(low, { w: 340, h: 180 }, view).top).toBe(508);
265
+ });
266
+
267
+ test("goes to the side when neither above nor below fits", () => {
268
+ const tall = { left: 40, top: 20, width: 200, height: 760 };
269
+ const p = placeCard(tall, { w: 340, h: 180 }, view);
270
+ expect(p.left).toBe(252); // to the right of the anchor
271
+ });
272
+
273
+ test("centers when nothing fits", () => {
274
+ const huge = { left: 0, top: 0, width: 1000, height: 800 };
275
+ expect(placeCard(huge, { w: 340, h: 180 }, view)).toEqual({ left: 330, top: 310 });
276
+ });
277
+
278
+ test("centers a step with no anchor at all", () => {
279
+ expect(placeCard(null, { w: 340, h: 180 }, view)).toEqual({ left: 330, top: 310 });
280
+ });
281
+
282
+ test("clamps a card that would hang off the edge", () => {
283
+ const edge = { left: 960, top: 100, width: 30, height: 30 };
284
+ const p = placeCard(edge, { w: 340, h: 180 }, view);
285
+ expect(p.left).toBe(1000 - 340 - 16);
286
+ });
287
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * css/tokens.css is the CSS projection of theme.ts, and nothing in the build
3
+ * forces the two to stay in step — the docs site reads the stylesheet while the
4
+ * SPAs read the theme object, so a drifted value would show up as two products
5
+ * quietly disagreeing about what "secondary text" means. These tests re-derive
6
+ * every shared token from theme.ts and assert the stylesheet still says the same
7
+ * thing. Change a value in theme.ts and this fails until the CSS follows.
8
+ */
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import { lightTheme, darkTheme, DESKTOP_BREAKPOINT, makeTheme } from "../theme";
12
+
13
+ const css = fs.readFileSync(path.join(__dirname, "../../css/tokens.css"), "utf8");
14
+
15
+ /** The `:root` / `[data-theme="dark"]` block bodies, in source order. */
16
+ function blocks(): { selector: string; body: string }[] {
17
+ const out: { selector: string; body: string }[] = [];
18
+ const re = /([^{}]+)\{([^}]*)\}/g;
19
+ let m: RegExpExecArray | null;
20
+ while ((m = re.exec(css))) {
21
+ out.push({ selector: m[1].trim().replace(/\s+/g, " "), body: m[2] });
22
+ }
23
+ return out;
24
+ }
25
+
26
+ /**
27
+ * Resolves a custom property the way the cascade would for `mode`: later
28
+ * declarations win, and a dark-scoped block only applies in dark mode.
29
+ */
30
+ function token(name: string, mode: "light" | "dark" = "light"): string | undefined {
31
+ let value: string | undefined;
32
+ for (const { selector, body } of blocks()) {
33
+ const isDarkScope = selector.includes('[data-theme="dark"]');
34
+ const isLightScope = selector.includes(":root");
35
+ const applies = mode === "dark" ? isDarkScope || isLightScope : isLightScope && !selector.startsWith("[");
36
+ if (!applies) continue;
37
+ const m = new RegExp(`(?:^|;|\\n)\\s*${name.replace(/-/g, "\\-")}\\s*:\\s*([^;]+)`).exec(body);
38
+ if (m) value = m[1].trim();
39
+ }
40
+ return value;
41
+ }
42
+
43
+ /** Follows `var(--x)` chains until a literal value is reached. */
44
+ function resolve(name: string, mode: "light" | "dark" = "light"): string | undefined {
45
+ let v = token(name, mode);
46
+ for (let i = 0; i < 8 && v?.startsWith("var("); i++) {
47
+ v = token(v.slice(4, -1).trim(), mode);
48
+ }
49
+ return v;
50
+ }
51
+
52
+ describe("ink ramp", () => {
53
+ for (const mode of ["light", "dark"] as const) {
54
+ const theme = makeTheme(mode);
55
+ it(`matches theme.ts in ${mode} mode`, () => {
56
+ for (const [step, hex] of Object.entries(theme.ink)) {
57
+ expect([`--ink-${step}`, resolve(`--ink-${step}`, mode)]).toEqual([`--ink-${step}`, hex]);
58
+ }
59
+ });
60
+ }
61
+ });
62
+
63
+ describe("semantic color aliases", () => {
64
+ // theme.ts key -> CSS custom property. Only the roles both platforms use.
65
+ const map: Record<string, string> = {
66
+ textPrimary: "--text-primary",
67
+ textSecondary: "--text-secondary",
68
+ textTertiary: "--text-tertiary",
69
+ textDisabled: "--text-disabled",
70
+ textInverted: "--text-inverted",
71
+ surfacePage: "--surface-page",
72
+ surfaceRaised: "--surface-raised",
73
+ surfaceTopBar: "--surface-topbar",
74
+ surfaceSunken: "--surface-sunken",
75
+ surfaceHover: "--surface-hover",
76
+ surfaceActive: "--surface-active",
77
+ surfaceInverted: "--surface-inverted",
78
+ borderSubtle: "--border-subtle",
79
+ borderDefault: "--border-default",
80
+ borderHover: "--border-hover",
81
+ borderStrong: "--border-strong",
82
+ borderFocus: "--border-focus",
83
+ borderActive: "--border-active",
84
+ diffAddFg: "--diff-add-fg",
85
+ diffAddBg: "--diff-add-bg",
86
+ diffDelFg: "--diff-del-fg",
87
+ diffDelBg: "--diff-del-bg",
88
+ };
89
+
90
+ for (const mode of ["light", "dark"] as const) {
91
+ const theme = mode === "dark" ? darkTheme : lightTheme;
92
+ it(`matches theme.ts in ${mode} mode`, () => {
93
+ for (const [key, prop] of Object.entries(map)) {
94
+ const want = (theme.color as Record<string, string>)[key];
95
+ expect([prop, resolve(prop, mode)]).toEqual([prop, want]);
96
+ }
97
+ });
98
+ }
99
+
100
+ it("declares the aliases inside the dark scope too", () => {
101
+ // A :root-only alias would keep resolving against the light ramp whenever
102
+ // [data-theme] sits on a nested container rather than <html>, which is how
103
+ // the dark top bar over a light page is built.
104
+ const darkAliasBlock = blocks().find(
105
+ (b) => b.selector.includes('[data-theme="dark"]') && b.body.includes("--text-primary:"),
106
+ );
107
+ expect(darkAliasBlock).toBeDefined();
108
+ });
109
+ });
110
+
111
+ describe("scales", () => {
112
+ it("has the type scale from theme.ts", () => {
113
+ const map: Record<string, keyof typeof lightTheme.type> = {
114
+ "--fs-display": "display",
115
+ "--fs-h1": "h1",
116
+ "--fs-h2": "h2",
117
+ "--fs-h3": "h3",
118
+ "--fs-body": "body",
119
+ "--fs-body-lg": "bodyLg",
120
+ "--fs-sm": "sm",
121
+ "--fs-xs": "xs",
122
+ "--fs-mono": "mono",
123
+ "--fs-mono-sm": "monoSm",
124
+ };
125
+ for (const [prop, key] of Object.entries(map)) {
126
+ expect([prop, resolve(prop)]).toEqual([prop, `${lightTheme.type[key].size}px`]);
127
+ const linePro = prop.replace("--fs-", "--lh-");
128
+ expect([linePro, resolve(linePro)]).toEqual([linePro, `${lightTheme.type[key].line}px`]);
129
+ }
130
+ });
131
+
132
+ it("has the font weights from theme.ts", () => {
133
+ for (const [key, weight] of Object.entries(lightTheme.weight)) {
134
+ const prop = `--fw-${key}`;
135
+ expect([prop, resolve(prop)]).toEqual([prop, weight]);
136
+ }
137
+ });
138
+
139
+ it("has the letter spacing from theme.ts", () => {
140
+ for (const [key, px] of Object.entries(lightTheme.letterSpacing)) {
141
+ const prop = `--ls-${key}`;
142
+ const want = px === 0 ? "0" : `${px}px`;
143
+ expect([prop, resolve(prop)]).toEqual([prop, want]);
144
+ }
145
+ });
146
+
147
+ it("has the 4px spacing grid from theme.ts", () => {
148
+ for (const n of [0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24]) {
149
+ const prop = `--space-${n}`;
150
+ const want = n === 0 ? "0" : `${lightTheme.space(n)}px`;
151
+ expect([prop, resolve(prop)]).toEqual([prop, want]);
152
+ }
153
+ });
154
+
155
+ it("has the radii from theme.ts", () => {
156
+ for (const [key, px] of Object.entries(lightTheme.radius)) {
157
+ const prop = `--radius-${key}`;
158
+ expect([prop, resolve(prop)]).toEqual([prop, `${px}px`]);
159
+ }
160
+ });
161
+
162
+ it("has the control heights from theme.ts", () => {
163
+ for (const [key, px] of Object.entries(lightTheme.control)) {
164
+ const prop = `--control-${key}`;
165
+ expect([prop, resolve(prop)]).toEqual([prop, `${px}px`]);
166
+ }
167
+ });
168
+
169
+ it("has the layout dims from theme.ts", () => {
170
+ const map: Record<string, keyof typeof lightTheme.layout> = {
171
+ "--sidebar-w": "sidebar",
172
+ "--column-w": "column",
173
+ "--topbar-h": "topbar",
174
+ "--container": "container",
175
+ };
176
+ for (const [prop, key] of Object.entries(map)) {
177
+ expect([prop, resolve(prop)]).toEqual([prop, `${lightTheme.layout[key]}px`]);
178
+ }
179
+ });
180
+
181
+ it("has the durations from theme.ts", () => {
182
+ for (const [key, ms] of Object.entries(lightTheme.duration)) {
183
+ const prop = `--dur-${key}`;
184
+ expect([prop, resolve(prop)]).toEqual([prop, `${ms}ms`]);
185
+ }
186
+ });
187
+
188
+ it("has the desktop breakpoint from theme.ts", () => {
189
+ expect(resolve("--desktop-breakpoint")).toBe(`${DESKTOP_BREAKPOINT}px`);
190
+ });
191
+ });
192
+
193
+ describe("font stacks", () => {
194
+ it("matches the web stacks theme.ts selects", () => {
195
+ // theme.ts resolves Platform.select to the web branch under jest's
196
+ // react-native-web mapping, so these are the exact strings the SPAs use.
197
+ const normalize = (s: string) => s.replace(/['"]/g, "").replace(/\s+/g, " ").trim();
198
+ expect(normalize(resolve("--font-sans")!)).toBe(normalize(lightTheme.font.sans));
199
+ expect(normalize(resolve("--font-mono")!)).toBe(normalize(lightTheme.font.mono));
200
+ });
201
+ });
@@ -0,0 +1,43 @@
1
+ import { componentAllowed, defaultComponent, resolveComponent } from "../fieldComponents";
2
+
3
+ // Mirror of packages/importer/schema/components_test.go — the Go file is the
4
+ // source of truth; these pin the TS mirror to the same behavior.
5
+
6
+ test("built-in components stay type-restricted", () => {
7
+ expect(componentAllowed("string", "slug")).toBe(true);
8
+ expect(componentAllowed("boolean", "slug")).toBe(false);
9
+ expect(componentAllowed("string", "nope")).toBe(false);
10
+ });
11
+
12
+ test("plugin: components are whitelisted for every type", () => {
13
+ for (const type of ["string", "boolean", "integer", "float", "array", "object"]) {
14
+ expect(componentAllowed(type, "plugin:color")).toBe(true);
15
+ }
16
+ // The bare prefix names nothing.
17
+ expect(componentAllowed("string", "plugin:")).toBe(false);
18
+ });
19
+
20
+ test("defaults are unaffected by the plugin whitelist", () => {
21
+ expect(defaultComponent("string")).toBe("input");
22
+ expect(resolveComponent({ type: "string" })).toBe("input");
23
+ expect(resolveComponent({ type: "string", component: "textarea" })).toBe("textarea");
24
+ });
25
+
26
+ // resolveComponent picks a *built-in* control. It is only ever consulted after
27
+ // the host has declined to render a plugin (createPluginRenderField returns
28
+ // undefined when the plugin is absent or refused the type), so returning the
29
+ // plugin's own name would name a control nothing implements — which fell
30
+ // through to the single-line input.
31
+ test("a plugin component falls back to the type's default control", () => {
32
+ expect(resolveComponent({ type: "string", component: "plugin:color" })).toBe("input");
33
+ expect(resolveComponent({ type: "array", component: "plugin:tags" })).toBe("list");
34
+ expect(resolveComponent({ type: "boolean", component: "plugin:toggle" })).toBe("checkbox");
35
+ });
36
+
37
+ // A field bound to the file body is a whole document's prose. Whatever the
38
+ // plugin would have done with it, the fallback has to be multi-line.
39
+ test("a body-source field falls back to the multiline control", () => {
40
+ expect(resolveComponent({ type: "string", component: "plugin:mdx", source: "body" })).toBe("textarea");
41
+ // `component: body` is a built-in and keeps rendering its own editor.
42
+ expect(resolveComponent({ type: "string", component: "body", source: "body" })).toBe("body");
43
+ });
@@ -0,0 +1,58 @@
1
+ import { clamp, reorder, slotAtX } from "../components/reorder";
2
+
3
+ describe("clamp", () => {
4
+ test("bounds a value into range", () => {
5
+ expect(clamp(5, 0, 10)).toBe(5);
6
+ expect(clamp(-3, 0, 10)).toBe(0);
7
+ expect(clamp(42, 0, 10)).toBe(10);
8
+ });
9
+ });
10
+
11
+ describe("reorder", () => {
12
+ const ids = ["a", "b", "c", "d"];
13
+
14
+ test("moves an item later in the list", () => {
15
+ // Drop "a" into the gap after "c" (slot 3 in original coords).
16
+ expect(reorder(ids, "a", 3)).toEqual(["b", "c", "a", "d"]);
17
+ });
18
+
19
+ test("moves an item earlier in the list", () => {
20
+ expect(reorder(ids, "d", 1)).toEqual(["a", "d", "b", "c"]);
21
+ });
22
+
23
+ test("dropping into the same position is a no-op ordering", () => {
24
+ expect(reorder(ids, "b", 1)).toEqual(["a", "b", "c", "d"]);
25
+ expect(reorder(ids, "b", 2)).toEqual(["a", "b", "c", "d"]);
26
+ });
27
+
28
+ test("slot past the end appends", () => {
29
+ expect(reorder(ids, "a", 4)).toEqual(["b", "c", "d", "a"]);
30
+ });
31
+
32
+ test("unknown id returns the input unchanged", () => {
33
+ expect(reorder(ids, "z", 0)).toBe(ids);
34
+ });
35
+ });
36
+
37
+ describe("slotAtX", () => {
38
+ const widths = [100, 100, 100];
39
+
40
+ test("before the first midpoint → slot 0", () => {
41
+ expect(slotAtX(10, widths)).toBe(0);
42
+ expect(slotAtX(49, widths)).toBe(0);
43
+ });
44
+
45
+ test("past a column's midpoint advances the slot", () => {
46
+ expect(slotAtX(60, widths)).toBe(1); // past col 0's midpoint (50)
47
+ expect(slotAtX(160, widths)).toBe(2); // past col 1's midpoint (150)
48
+ });
49
+
50
+ test("past the last midpoint → final slot", () => {
51
+ expect(slotAtX(500, widths)).toBe(3);
52
+ });
53
+
54
+ test("handles uneven widths", () => {
55
+ expect(slotAtX(30, [40, 200])).toBe(1); // past the narrow first column (mid 20)
56
+ expect(slotAtX(10, [40, 200])).toBe(0);
57
+ });
58
+ });