@kingsleyweb/bun-ui 0.0.0-stage → 3.0.0-rc.2

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 (240) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/app/docs/App.tsx +72 -0
  4. package/app/docs/api/client.ts +303 -0
  5. package/app/docs/api/contract.ts +24 -0
  6. package/app/docs/api/docs.ts +38 -0
  7. package/app/docs/api/errors.ts +144 -0
  8. package/app/docs/boot.tsx +58 -0
  9. package/app/docs/components/Badge.tsx +65 -0
  10. package/app/docs/components/Button.tsx +37 -0
  11. package/app/docs/components/Card.tsx +41 -0
  12. package/app/docs/components/ConfirmDialog.tsx +173 -0
  13. package/app/docs/components/CopyButton.tsx +68 -0
  14. package/app/docs/components/Dialog.tsx +230 -0
  15. package/app/docs/components/EmptyState.tsx +34 -0
  16. package/app/docs/components/ErrorView.tsx +70 -0
  17. package/app/docs/components/Field.tsx +93 -0
  18. package/app/docs/components/JsonEditor.tsx +151 -0
  19. package/app/docs/components/JsonView.tsx +338 -0
  20. package/app/docs/components/KeyValue.tsx +63 -0
  21. package/app/docs/components/ProblemBanner.tsx +116 -0
  22. package/app/docs/components/ScreenErrorBoundary.tsx +141 -0
  23. package/app/docs/components/Spinner.tsx +33 -0
  24. package/app/docs/components/Table.tsx +33 -0
  25. package/app/docs/components/Tabs.tsx +226 -0
  26. package/app/docs/components/classNames.ts +4 -0
  27. package/app/docs/components/components.css +691 -0
  28. package/app/docs/components/copy.ts +89 -0
  29. package/app/docs/components/fieldContext.ts +29 -0
  30. package/app/docs/components/inputValues.ts +142 -0
  31. package/app/docs/components/inputs.tsx +387 -0
  32. package/app/docs/components/jsonParse.ts +338 -0
  33. package/app/docs/components/listKeys.ts +30 -0
  34. package/app/docs/config.ts +86 -0
  35. package/app/docs/context.ts +33 -0
  36. package/app/docs/embed.ts +74 -0
  37. package/app/docs/env.d.ts +2 -0
  38. package/app/docs/format.ts +9 -0
  39. package/app/docs/hooks/useUrlTab.ts +16 -0
  40. package/app/docs/main.tsx +11 -0
  41. package/app/docs/queryClient.ts +54 -0
  42. package/app/docs/router.tsx +194 -0
  43. package/app/docs/routing.ts +194 -0
  44. package/app/docs/screens/docs/document.ts +27 -0
  45. package/app/docs/screens/docs/http/OperationView.tsx +407 -0
  46. package/app/docs/screens/docs/http/Sidebar.tsx +190 -0
  47. package/app/docs/screens/docs/http/TryItPanel.tsx +603 -0
  48. package/app/docs/screens/docs/http/http.css +405 -0
  49. package/app/docs/screens/docs/http/index.tsx +395 -0
  50. package/app/docs/screens/docs/http/model.ts +604 -0
  51. package/app/docs/screens/docs/http/tryIt.ts +455 -0
  52. package/app/docs/screens/docs/permissions.ts +6 -0
  53. package/app/docs/screens/docs/schema/Prose.tsx +43 -0
  54. package/app/docs/screens/docs/schema/SchemaTree.tsx +319 -0
  55. package/app/docs/screens/docs/schema/index.ts +30 -0
  56. package/app/docs/screens/docs/schema/resolve.ts +466 -0
  57. package/app/docs/screens/docs/schema/schema.css +137 -0
  58. package/app/docs/screens/docs/schema/skeleton.ts +92 -0
  59. package/app/docs/screens/docs/viewer.tsx +154 -0
  60. package/app/docs/screens/docs/ws/InlineText.tsx +25 -0
  61. package/app/docs/screens/docs/ws/PayloadSchema.tsx +39 -0
  62. package/app/docs/screens/docs/ws/format.ts +82 -0
  63. package/app/docs/screens/docs/ws/index.tsx +286 -0
  64. package/app/docs/screens/docs/ws/items.tsx +786 -0
  65. package/app/docs/screens/docs/ws/model.ts +1178 -0
  66. package/app/docs/screens/docs/ws/panels.tsx +440 -0
  67. package/app/docs/screens/docs/ws/ws.css +231 -0
  68. package/app/docs/shortcuts.ts +94 -0
  69. package/app/docs/styles/base.css +847 -0
  70. package/app/docs/styles/tokens.css +143 -0
  71. package/app/docs/theme.ts +56 -0
  72. package/app/kit/unauthorized.ts +222 -0
  73. package/dist/docs/assets/main-0rchxtv5.js +18 -0
  74. package/dist/docs/assets/main-0rchxtv5.js.map +99 -0
  75. package/dist/docs/assets/main-cqgyvs86.css +1 -0
  76. package/dist/docs/manifest.json +26 -0
  77. package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js +6 -0
  78. package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js.map +11 -0
  79. package/dist/jobs/assets/AddFlowDialog-n7gtc84v.css +1 -0
  80. package/dist/jobs/assets/added-cbwsxv3e.js +4 -0
  81. package/dist/jobs/assets/added-cbwsxv3e.js.map +11 -0
  82. package/dist/jobs/assets/bodies-yyw5hmrp.js +6 -0
  83. package/dist/jobs/assets/bodies-yyw5hmrp.js.map +10 -0
  84. package/dist/jobs/assets/channel-tgkvqm8y.js +4 -0
  85. package/dist/jobs/assets/channel-tgkvqm8y.js.map +10 -0
  86. package/dist/jobs/assets/channelChecks-s4yz4yhw.js +4 -0
  87. package/dist/jobs/assets/channelChecks-s4yz4yhw.js.map +10 -0
  88. package/dist/jobs/assets/index-02427smp.css +1 -0
  89. package/dist/jobs/assets/index-09z2cjft.js +4 -0
  90. package/dist/jobs/assets/index-09z2cjft.js.map +16 -0
  91. package/dist/jobs/assets/index-18bn0gvy.css +1 -0
  92. package/dist/jobs/assets/index-47pxmyxn.js +4 -0
  93. package/dist/jobs/assets/index-47pxmyxn.js.map +10 -0
  94. package/dist/jobs/assets/index-4e55z986.js +4 -0
  95. package/dist/jobs/assets/index-4e55z986.js.map +11 -0
  96. package/dist/jobs/assets/index-5c933424.js +4 -0
  97. package/dist/jobs/assets/index-5c933424.js.map +18 -0
  98. package/dist/jobs/assets/index-5kbyqspm.css +1 -0
  99. package/dist/jobs/assets/index-5ng37hk6.css +1 -0
  100. package/dist/jobs/assets/index-5w8w7fvc.css +1 -0
  101. package/dist/jobs/assets/index-71qs5qyx.css +1 -0
  102. package/dist/jobs/assets/index-btpg4r8b.css +1 -0
  103. package/dist/jobs/assets/index-d5r5n0mm.js +4 -0
  104. package/dist/jobs/assets/index-d5r5n0mm.js.map +23 -0
  105. package/dist/jobs/assets/index-e0yy2skq.css +1 -0
  106. package/dist/jobs/assets/index-hs60b25v.js +10 -0
  107. package/dist/jobs/assets/index-hs60b25v.js.map +62 -0
  108. package/dist/jobs/assets/index-k2smrr70.js +4 -0
  109. package/dist/jobs/assets/index-k2smrr70.js.map +9 -0
  110. package/dist/jobs/assets/index-q24jw3w9.js +4 -0
  111. package/dist/jobs/assets/index-q24jw3w9.js.map +32 -0
  112. package/dist/jobs/assets/index-qfb80xen.css +1 -0
  113. package/dist/jobs/assets/index-rtynx6wj.js +4 -0
  114. package/dist/jobs/assets/index-rtynx6wj.js.map +10 -0
  115. package/dist/jobs/assets/live-6k7s6358.js +4 -0
  116. package/dist/jobs/assets/live-6k7s6358.js.map +10 -0
  117. package/dist/jobs/assets/main-0jmeh6mq.js +6 -0
  118. package/dist/jobs/assets/main-0jmeh6mq.js.map +12 -0
  119. package/dist/jobs/assets/main-3yj6bv90.js +5 -0
  120. package/dist/jobs/assets/main-3yj6bv90.js.map +10 -0
  121. package/dist/jobs/assets/main-4g69fn3x.js +5 -0
  122. package/dist/jobs/assets/main-4g69fn3x.js.map +10 -0
  123. package/dist/jobs/assets/main-4gte3eqq.js +5 -0
  124. package/dist/jobs/assets/main-4gte3eqq.js.map +10 -0
  125. package/dist/jobs/assets/main-4j1fcevh.js +5 -0
  126. package/dist/jobs/assets/main-4j1fcevh.js.map +10 -0
  127. package/dist/jobs/assets/main-54qwkz28.css +1 -0
  128. package/dist/jobs/assets/main-5grjvsy7.js +5 -0
  129. package/dist/jobs/assets/main-5grjvsy7.js.map +11 -0
  130. package/dist/jobs/assets/main-5zbx60gq.js +5 -0
  131. package/dist/jobs/assets/main-5zbx60gq.js.map +11 -0
  132. package/dist/jobs/assets/main-9prgjhg4.js +5 -0
  133. package/dist/jobs/assets/main-9prgjhg4.js.map +10 -0
  134. package/dist/jobs/assets/main-a250ffk7.js +17 -0
  135. package/dist/jobs/assets/main-a250ffk7.js.map +115 -0
  136. package/dist/jobs/assets/main-d6ge7w08.js +5 -0
  137. package/dist/jobs/assets/main-d6ge7w08.js.map +10 -0
  138. package/dist/jobs/assets/main-gngzht43.js +5 -0
  139. package/dist/jobs/assets/main-gngzht43.js.map +10 -0
  140. package/dist/jobs/assets/main-hg0hrg8j.js +5 -0
  141. package/dist/jobs/assets/main-hg0hrg8j.js.map +10 -0
  142. package/dist/jobs/assets/main-hm1ng3yg.js +5 -0
  143. package/dist/jobs/assets/main-hm1ng3yg.js.map +12 -0
  144. package/dist/jobs/assets/main-jkvd92z9.js +5 -0
  145. package/dist/jobs/assets/main-jkvd92z9.js.map +10 -0
  146. package/dist/jobs/assets/main-jv5xqd13.js +5 -0
  147. package/dist/jobs/assets/main-jv5xqd13.js.map +21 -0
  148. package/dist/jobs/assets/main-jxre7w93.js +5 -0
  149. package/dist/jobs/assets/main-jxre7w93.js.map +23 -0
  150. package/dist/jobs/assets/main-kwmqkjfx.js +5 -0
  151. package/dist/jobs/assets/main-kwmqkjfx.js.map +11 -0
  152. package/dist/jobs/assets/main-n9cf6bxm.js +5 -0
  153. package/dist/jobs/assets/main-n9cf6bxm.js.map +11 -0
  154. package/dist/jobs/assets/main-pegp0ygb.js +5 -0
  155. package/dist/jobs/assets/main-pegp0ygb.js.map +10 -0
  156. package/dist/jobs/assets/main-qr573853.js +5 -0
  157. package/dist/jobs/assets/main-qr573853.js.map +12 -0
  158. package/dist/jobs/assets/main-rq97rdr4.js +4 -0
  159. package/dist/jobs/assets/main-rq97rdr4.js.map +22 -0
  160. package/dist/jobs/assets/main-szjdmydc.js +5 -0
  161. package/dist/jobs/assets/main-szjdmydc.js.map +13 -0
  162. package/dist/jobs/assets/main-tnbhrm9n.js +7 -0
  163. package/dist/jobs/assets/main-tnbhrm9n.js.map +11 -0
  164. package/dist/jobs/assets/main-ts19j4n0.js +5 -0
  165. package/dist/jobs/assets/main-ts19j4n0.js.map +25 -0
  166. package/dist/jobs/assets/main-w93x1dzs.js +5 -0
  167. package/dist/jobs/assets/main-w93x1dzs.js.map +10 -0
  168. package/dist/jobs/assets/sections-0ksdxs8m.css +1 -0
  169. package/dist/jobs/assets/sections-0y7w4fb4.js +4 -0
  170. package/dist/jobs/assets/sections-0y7w4fb4.js.map +10 -0
  171. package/dist/jobs/assets/sse-mn0t0fh7.js +5 -0
  172. package/dist/jobs/assets/sse-mn0t0fh7.js.map +11 -0
  173. package/dist/jobs/assets/sseChecks-t7tcm7ra.js +4 -0
  174. package/dist/jobs/assets/sseChecks-t7tcm7ra.js.map +10 -0
  175. package/dist/jobs/assets/validation-ysjfy072.js +4 -0
  176. package/dist/jobs/assets/validation-ysjfy072.js.map +9 -0
  177. package/dist/jobs/manifest.json +511 -0
  178. package/dts/docs/assets.d.ts +144 -0
  179. package/dts/docs/assets.d.ts.map +1 -0
  180. package/dts/docs/browser/config.d.ts +61 -0
  181. package/dts/docs/browser/config.d.ts.map +1 -0
  182. package/dts/docs/browser/index.d.ts +12 -0
  183. package/dts/docs/browser/index.d.ts.map +1 -0
  184. package/dts/docs/docsUi.d.ts +42 -0
  185. package/dts/docs/docsUi.d.ts.map +1 -0
  186. package/dts/docs/hosted.d.ts +22 -0
  187. package/dts/docs/hosted.d.ts.map +1 -0
  188. package/dts/docs/index.d.ts +18 -0
  189. package/dts/docs/index.d.ts.map +1 -0
  190. package/dts/docs/shell.d.ts +57 -0
  191. package/dts/docs/shell.d.ts.map +1 -0
  192. package/dts/docs/types.d.ts +137 -0
  193. package/dts/docs/types.d.ts.map +1 -0
  194. package/dts/index.d.ts +21 -0
  195. package/dts/index.d.ts.map +1 -0
  196. package/dts/jobs/assets.d.ts +157 -0
  197. package/dts/jobs/assets.d.ts.map +1 -0
  198. package/dts/jobs/browser/config.d.ts +67 -0
  199. package/dts/jobs/browser/config.d.ts.map +1 -0
  200. package/dts/jobs/browser/index.d.ts +10 -0
  201. package/dts/jobs/browser/index.d.ts.map +1 -0
  202. package/dts/jobs/browser/unauthorized.d.ts +9 -0
  203. package/dts/jobs/browser/unauthorized.d.ts.map +1 -0
  204. package/dts/jobs/index.d.ts +24 -0
  205. package/dts/jobs/index.d.ts.map +1 -0
  206. package/dts/jobs/jobsUi.d.ts +63 -0
  207. package/dts/jobs/jobsUi.d.ts.map +1 -0
  208. package/dts/jobs/shell.d.ts +77 -0
  209. package/dts/jobs/shell.d.ts.map +1 -0
  210. package/dts/jobs/types.d.ts +146 -0
  211. package/dts/jobs/types.d.ts.map +1 -0
  212. package/dts/kit/browser/shared.d.ts +10 -0
  213. package/dts/kit/browser/shared.d.ts.map +1 -0
  214. package/dts/kit/browser/unauthorized.d.ts +74 -0
  215. package/dts/kit/browser/unauthorized.d.ts.map +1 -0
  216. package/dts/kit/unauthorized.d.ts +47 -0
  217. package/dts/kit/unauthorized.d.ts.map +1 -0
  218. package/guides/docs.md +170 -0
  219. package/guides/jobs.md +1120 -0
  220. package/lib/docs/assets.ts +427 -0
  221. package/lib/docs/browser/config.ts +66 -0
  222. package/lib/docs/browser/index.ts +24 -0
  223. package/lib/docs/docsUi.ts +623 -0
  224. package/lib/docs/hosted.ts +31 -0
  225. package/lib/docs/index.ts +37 -0
  226. package/lib/docs/shell.ts +156 -0
  227. package/lib/docs/types.ts +149 -0
  228. package/lib/index.ts +27 -0
  229. package/lib/jobs/assets.ts +448 -0
  230. package/lib/jobs/browser/config.ts +65 -0
  231. package/lib/jobs/browser/index.ts +9 -0
  232. package/lib/jobs/browser/unauthorized.ts +11 -0
  233. package/lib/jobs/index.ts +41 -0
  234. package/lib/jobs/jobsUi.ts +709 -0
  235. package/lib/jobs/shell.ts +205 -0
  236. package/lib/jobs/types.ts +162 -0
  237. package/lib/kit/browser/shared.ts +11 -0
  238. package/lib/kit/browser/unauthorized.ts +114 -0
  239. package/lib/kit/unauthorized.ts +222 -0
  240. package/package.json +198 -4
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** Props of {@link EmptyState}. */
4
+ export interface EmptyStateProps {
5
+ /** The headline, e.g. "No queues yet". */
6
+ title: string;
7
+ /** A sentence of explanation. */
8
+ description?: ReactNode;
9
+ /** A follow-up control. */
10
+ action?: ReactNode;
11
+ /**
12
+ * Render the headline as a heading of this level instead of a paragraph.
13
+ * Use `1` when the empty state IS the screen (a not-found or hidden screen
14
+ * has no other `h1`). Defaults to none (a paragraph).
15
+ */
16
+ headingLevel?: 1 | 2 | 3;
17
+ }
18
+
19
+ /** What a list or screen shows when there is nothing to show. */
20
+ export function EmptyState({
21
+ title,
22
+ description,
23
+ action,
24
+ headingLevel,
25
+ }: EmptyStateProps) {
26
+ const Title = headingLevel ? (`h${headingLevel}` as const) : "p";
27
+ return (
28
+ <div className="empty-state">
29
+ <Title className="empty-state-title">{title}</Title>
30
+ {description && <p className="empty-state-description">{description}</p>}
31
+ {action && <div className="empty-state-action">{action}</div>}
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,70 @@
1
+ import { isApiError } from "../api/errors";
2
+ import { Button } from "./Button";
3
+
4
+ /** Props of {@link ErrorView}. */
5
+ export interface ErrorViewProps {
6
+ /** What failed: an `ApiError` renders its problem fields, anything else a generic message. */
7
+ error: unknown;
8
+ /** A heading override, e.g. "Could not load queues". */
9
+ title?: string;
10
+ /** Offers a retry button when given. */
11
+ onRetry?: () => void;
12
+ /**
13
+ * Render the heading as a heading of this level instead of a paragraph.
14
+ * Use `1` when the error replaces the whole screen, so it still has its
15
+ * `h1`. Defaults to none (a paragraph).
16
+ */
17
+ headingLevel?: 1 | 2 | 3;
18
+ }
19
+
20
+ /** Renders a failed request: title, detail, code/status, and validation issues. */
21
+ export function ErrorView({
22
+ error,
23
+ title,
24
+ onRetry,
25
+ headingLevel,
26
+ }: ErrorViewProps) {
27
+ const Title = headingLevel ? (`h${headingLevel}` as const) : "p";
28
+ const api = isApiError(error) ? error : null;
29
+ const heading = title ?? api?.title ?? "Something went wrong";
30
+ const detail =
31
+ api?.detail ?? (error instanceof Error ? error.message : String(error));
32
+ return (
33
+ <div
34
+ className="error-view"
35
+ role="alert"
36
+ >
37
+ <Title className="error-view-title">{heading}</Title>
38
+ {detail && detail !== heading && (
39
+ <p className="error-view-detail">{detail}</p>
40
+ )}
41
+ {api && (
42
+ <p className="error-view-meta">
43
+ <code>{api.code}</code>
44
+ {api.status > 0 && <span> · HTTP {api.status}</span>}
45
+ </p>
46
+ )}
47
+ {api && api.issues.length > 0 && (
48
+ <ul className="error-view-issues">
49
+ {api.issues.map((issue) => (
50
+ <li key={`${issue.target}:${issue.path}:${issue.message}`}>
51
+ <code>
52
+ {issue.target}
53
+ {issue.path ? `.${issue.path}` : ""}
54
+ </code>{" "}
55
+ {issue.message}
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ )}
60
+ {onRetry && (
61
+ <Button
62
+ variant="secondary"
63
+ onClick={onRetry}
64
+ >
65
+ Retry
66
+ </Button>
67
+ )}
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,93 @@
1
+ import type { ReactNode } from "react";
2
+ import type { FieldControlProps } from "./fieldContext";
3
+ import { useId, useMemo } from "react";
4
+ import { cx } from "./classNames";
5
+ import { FieldContext, joinIds } from "./fieldContext";
6
+
7
+ /** Props of {@link Field}. */
8
+ export interface FieldProps {
9
+ /** The visible label, a `<label>` for the control. */
10
+ label: ReactNode;
11
+ /** Help under the control; it describes the control (`aria-describedby`). */
12
+ hint?: ReactNode;
13
+ /** An error under the control; it describes the control and sets `aria-invalid`. Falsy means valid. */
14
+ error?: ReactNode;
15
+ /** Show a required marker and mark the control required. Defaults to `false`. */
16
+ required?: boolean;
17
+ /** The control's id. Defaults to a generated one. */
18
+ id?: string;
19
+ /** Extra class names on the wrapper. */
20
+ className?: string;
21
+ /** The control: one of this kit's inputs (they read the wiring from context), or a render function receiving it for anything else. */
22
+ children: ReactNode | ((control: FieldControlProps) => ReactNode);
23
+ }
24
+
25
+ /**
26
+ * A labelled form row: label, control, hint and error. The kit's inputs
27
+ * ({@link TextInput}, {@link NumberInput}, {@link Select},
28
+ * {@link DateTimeInput}, `JsonEditor`) pick up the id, `aria-describedby`,
29
+ * `aria-invalid` and `required` from it; a plain element can take them from
30
+ * the render-function form of `children`.
31
+ */
32
+ export function Field({
33
+ label,
34
+ hint,
35
+ error,
36
+ required = false,
37
+ id,
38
+ className,
39
+ children,
40
+ }: FieldProps) {
41
+ const generatedId = useId();
42
+ const controlId = id ?? generatedId;
43
+ const hintId = `${controlId}-hint`;
44
+ const errorId = `${controlId}-error`;
45
+ const hasError = Boolean(error);
46
+ const control = useMemo<FieldControlProps>(
47
+ () => ({
48
+ id: controlId,
49
+ "aria-describedby": joinIds(hint ? hintId : null, hasError && errorId),
50
+ "aria-invalid": hasError || undefined,
51
+ required: required || undefined,
52
+ }),
53
+ [controlId, hint, hintId, hasError, errorId, required],
54
+ );
55
+ return (
56
+ <div className={cx("field", hasError && "field-invalid", className)}>
57
+ <label
58
+ className="field-label"
59
+ htmlFor={controlId}
60
+ >
61
+ {label}
62
+ {required && (
63
+ <span
64
+ className="field-required"
65
+ aria-hidden="true"
66
+ >
67
+ {" "}
68
+ *
69
+ </span>
70
+ )}
71
+ </label>
72
+ <FieldContext value={control}>
73
+ {typeof children === "function" ? children(control) : children}
74
+ </FieldContext>
75
+ {hint && (
76
+ <p
77
+ id={hintId}
78
+ className="field-hint"
79
+ >
80
+ {hint}
81
+ </p>
82
+ )}
83
+ {hasError && (
84
+ <p
85
+ id={errorId}
86
+ className="field-error"
87
+ >
88
+ {error}
89
+ </p>
90
+ )}
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,151 @@
1
+ import type { JsonEditorState, JsonEditorStateOptions } from "./jsonParse";
2
+ import { useId, useMemo } from "react";
3
+ import { Button } from "./Button";
4
+ import { cx } from "./classNames";
5
+ import { joinIds, useField } from "./fieldContext";
6
+ import { formatBytes, jsonEditorState } from "./jsonParse";
7
+
8
+ /** Props of {@link JsonEditor}. */
9
+ export interface JsonEditorProps extends JsonEditorStateOptions {
10
+ /** The text (controlled). Seed it with `JSON.stringify(value, null, 2)`. */
11
+ value: string;
12
+ /** Called on every edit, format and minify with the new state. */
13
+ onChange: (state: JsonEditorState) => void;
14
+ /** Visible rows. Defaults to `10`. */
15
+ rows?: number;
16
+ /** Accessible name when not inside a `Field`. */
17
+ "aria-label"?: string;
18
+ /** The textarea's id. Defaults to the enclosing field's, or a generated one. */
19
+ id?: string;
20
+ /** Read-only: no editing, no format/minify. */
21
+ readOnly?: boolean;
22
+ /** Disabled. */
23
+ disabled?: boolean;
24
+ /** Placeholder text. */
25
+ placeholder?: string;
26
+ /** Extra class names on the wrapper. */
27
+ className?: string;
28
+ }
29
+
30
+ /**
31
+ * A plain-textarea JSON editor: the parse error with line and column, a
32
+ * UTF-8 size meter against `maxBytes` (turning red over it), and Format /
33
+ * Minify. Inside a `Field` it takes the field's id and description.
34
+ */
35
+ export function JsonEditor({
36
+ value: text,
37
+ onChange,
38
+ maxBytes,
39
+ allowEmpty = false,
40
+ rows = 10,
41
+ "aria-label": ariaLabel,
42
+ id,
43
+ readOnly = false,
44
+ disabled = false,
45
+ placeholder,
46
+ className,
47
+ }: JsonEditorProps) {
48
+ const field = useField();
49
+ const generatedId = useId();
50
+ const inputId = id ?? field?.id ?? generatedId;
51
+ const errorId = `${inputId}-json-error`;
52
+ const sizeId = `${inputId}-json-size`;
53
+ const state = useMemo(
54
+ () => jsonEditorState(text, { maxBytes, allowEmpty }),
55
+ [text, maxBytes, allowEmpty],
56
+ );
57
+ const emit = (next: string) =>
58
+ onChange(jsonEditorState(next, { maxBytes, allowEmpty }));
59
+ const reformat = (indent: number | undefined) => {
60
+ if (state.error === null && state.value !== undefined) {
61
+ emit(JSON.stringify(state.value, null, indent));
62
+ }
63
+ };
64
+ const canFormat =
65
+ !readOnly && !disabled && state.error === null && state.value !== undefined;
66
+
67
+ return (
68
+ <div
69
+ className={cx(
70
+ "json-editor",
71
+ !state.valid && "json-editor-invalid",
72
+ className,
73
+ )}
74
+ >
75
+ <textarea
76
+ id={inputId}
77
+ className="input json-editor-text"
78
+ rows={rows}
79
+ value={text}
80
+ placeholder={placeholder}
81
+ readOnly={readOnly}
82
+ disabled={disabled}
83
+ spellCheck={false}
84
+ autoCapitalize="off"
85
+ autoComplete="off"
86
+ aria-label={ariaLabel}
87
+ aria-invalid={!state.valid || field?.["aria-invalid"] || undefined}
88
+ aria-required={field?.required}
89
+ aria-describedby={joinIds(
90
+ field?.["aria-describedby"],
91
+ state.error && errorId,
92
+ sizeId,
93
+ )}
94
+ onChange={(event) => emit(event.target.value)}
95
+ />
96
+ <div className="json-editor-bar">
97
+ {state.error ? (
98
+ <p
99
+ id={errorId}
100
+ className="json-editor-error"
101
+ >
102
+ Line {state.error.line}, column {state.error.column}:{" "}
103
+ {state.error.message}
104
+ </p>
105
+ ) : (
106
+ <p className="json-editor-ok muted">Valid JSON</p>
107
+ )}
108
+ <span
109
+ id={sizeId}
110
+ className={cx("json-editor-size", state.overLimit && "is-over")}
111
+ >
112
+ {maxBytes !== undefined && (
113
+ <meter
114
+ className="json-editor-meter"
115
+ min={0}
116
+ max={maxBytes}
117
+ low={maxBytes * 0.8}
118
+ high={maxBytes}
119
+ optimum={0}
120
+ value={Math.min(state.bytes, maxBytes)}
121
+ aria-hidden="true"
122
+ />
123
+ )}
124
+ {maxBytes === undefined
125
+ ? formatBytes(state.bytes)
126
+ : state.overLimit
127
+ ? `${formatBytes(state.bytes)} of ${formatBytes(maxBytes)}: over the limit by ${formatBytes(state.bytes - maxBytes)}`
128
+ : `${formatBytes(state.bytes)} of ${formatBytes(maxBytes)}`}
129
+ </span>
130
+ {!readOnly && (
131
+ <span className="json-editor-actions">
132
+ <Button
133
+ size="sm"
134
+ disabled={!canFormat}
135
+ onClick={() => reformat(2)}
136
+ >
137
+ Format
138
+ </Button>
139
+ <Button
140
+ size="sm"
141
+ disabled={!canFormat}
142
+ onClick={() => reformat(undefined)}
143
+ >
144
+ Minify
145
+ </Button>
146
+ </span>
147
+ )}
148
+ </div>
149
+ </div>
150
+ );
151
+ }
@@ -0,0 +1,338 @@
1
+ import type { ReactNode } from "react";
2
+ import { useState } from "react";
3
+ import { cx } from "./classNames";
4
+ import { safeStringify } from "./copy";
5
+ import { CopyButton } from "./CopyButton";
6
+
7
+ /** Limits of a {@link JsonView}. */
8
+ interface ViewLimits {
9
+ /** Levels expanded on first render. */
10
+ expandDepth: number;
11
+ /** Characters of a string shown before "show more". */
12
+ maxStringLength: number;
13
+ /** Children rendered per expand (and per "show more"). */
14
+ maxChildren: number;
15
+ }
16
+
17
+ /** Props of {@link JsonView}. */
18
+ export interface JsonViewProps {
19
+ /** Any value: JSON, `undefined`, a `Date`, a `bigint`, even a circular object. */
20
+ value: unknown;
21
+ /** Accessible name of the tree, e.g. "Job data". Defaults to `"JSON"`. */
22
+ label?: string;
23
+ /** Levels expanded on first render; `0` starts collapsed. Defaults to `1`. */
24
+ expandDepth?: number;
25
+ /** Characters of a string shown before a "show more" control. Defaults to `200`. */
26
+ maxStringLength?: number;
27
+ /** Array items / object keys rendered per expand; "show more" renders the next batch. Defaults to `100`. */
28
+ maxChildren?: number;
29
+ /** Show a Copy button for the whole value (serialised on click, circular-safe). Defaults to `true`. */
30
+ copyable?: boolean;
31
+ /** Extra class names. */
32
+ className?: string;
33
+ }
34
+
35
+ /** What a value looks like to the viewer. */
36
+ type Kind =
37
+ | "object"
38
+ | "array"
39
+ | "string"
40
+ | "number"
41
+ | "boolean"
42
+ | "null"
43
+ | "undefined"
44
+ | "other";
45
+
46
+ /** Classifies a value for rendering. */
47
+ function kindOf(value: unknown): Kind {
48
+ if (value === null) {
49
+ return "null";
50
+ }
51
+ if (Array.isArray(value)) {
52
+ return "array";
53
+ }
54
+ switch (typeof value) {
55
+ case "object":
56
+ return value instanceof Date ? "other" : "object";
57
+ case "string":
58
+ return "string";
59
+ case "number":
60
+ case "bigint":
61
+ return "number";
62
+ case "boolean":
63
+ return "boolean";
64
+ case "undefined":
65
+ return "undefined";
66
+ default:
67
+ return "other";
68
+ }
69
+ }
70
+
71
+ /** A primitive's (or opaque value's) text. */
72
+ function scalarText(value: unknown): string {
73
+ if (typeof value === "bigint") {
74
+ return `${value}n`;
75
+ }
76
+ if (value instanceof Date) {
77
+ return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
78
+ }
79
+ if (typeof value === "symbol" || typeof value === "function") {
80
+ return value.toString();
81
+ }
82
+ return String(value);
83
+ }
84
+
85
+ /** The first `limit` entries of a container, arrays keyed by index; never touches the rest. */
86
+ function entriesOf(value: object, limit: number): [string, unknown][] {
87
+ if (Array.isArray(value)) {
88
+ return value
89
+ .slice(0, limit)
90
+ .map((item: unknown, index) => [String(index), item]);
91
+ }
92
+ const record = value as Record<string, unknown>;
93
+ return Object.keys(record)
94
+ .slice(0, limit)
95
+ .map((key) => [key, record[key]]);
96
+ }
97
+
98
+ /**
99
+ * A read-only, collapsible tree of any value. Built for job payloads of any
100
+ * size: levels past `expandDepth` start collapsed, each expand renders at
101
+ * most `maxChildren` children (with "show more"), long strings are cut at
102
+ * `maxStringLength`, and a reference back to an ancestor renders as
103
+ * `[Circular]` instead of recursing.
104
+ */
105
+ export function JsonView({
106
+ value,
107
+ label = "JSON",
108
+ expandDepth = 1,
109
+ maxStringLength = 200,
110
+ maxChildren = 100,
111
+ copyable = true,
112
+ className,
113
+ }: JsonViewProps) {
114
+ const limits: ViewLimits = { expandDepth, maxStringLength, maxChildren };
115
+ return (
116
+ <div className={cx("json-view", className)}>
117
+ {copyable && (
118
+ <div className="json-view-toolbar">
119
+ <CopyButton
120
+ text={() => safeStringify(value, 2)}
121
+ ariaLabel={`Copy ${label}`}
122
+ />
123
+ </div>
124
+ )}
125
+ <ul
126
+ className="json-tree"
127
+ aria-label={label}
128
+ >
129
+ <JsonNode
130
+ name={null}
131
+ value={value}
132
+ depth={0}
133
+ ancestors={[]}
134
+ limits={limits}
135
+ />
136
+ </ul>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ /** Props of {@link JsonNode}. */
142
+ interface JsonNodeProps {
143
+ /** The key or index, or `null` at the root. */
144
+ name: string | null;
145
+ /** The value at this node. */
146
+ value: unknown;
147
+ /** Nesting level, root = 0. */
148
+ depth: number;
149
+ /** The containers above this node, for the circular check. */
150
+ ancestors: readonly object[];
151
+ /** Rendering limits. */
152
+ limits: ViewLimits;
153
+ }
154
+
155
+ /** One key/value row, expandable when the value is a non-empty container. */
156
+ function JsonNode({ name, value, depth, ancestors, limits }: JsonNodeProps) {
157
+ const kind = kindOf(value);
158
+ const key = name === null ? null : <span className="json-key">{name}: </span>;
159
+
160
+ if (kind !== "object" && kind !== "array") {
161
+ return (
162
+ <li className="json-row">
163
+ {key}
164
+ <JsonScalar
165
+ value={value}
166
+ kind={kind}
167
+ maxLength={limits.maxStringLength}
168
+ />
169
+ </li>
170
+ );
171
+ }
172
+
173
+ const container = value as object;
174
+ if (ancestors.includes(container)) {
175
+ return (
176
+ <li className="json-row">
177
+ {key}
178
+ <span className="json-circular">[Circular]</span>
179
+ </li>
180
+ );
181
+ }
182
+ return (
183
+ <JsonContainer
184
+ nameNode={key}
185
+ value={container}
186
+ isArray={kind === "array"}
187
+ depth={depth}
188
+ ancestors={ancestors}
189
+ limits={limits}
190
+ />
191
+ );
192
+ }
193
+
194
+ /** Props of {@link JsonContainer}. */
195
+ interface JsonContainerProps {
196
+ /** The rendered key, or `null` at the root. */
197
+ nameNode: ReactNode;
198
+ /** The object or array. */
199
+ value: object;
200
+ /** Whether it is an array. */
201
+ isArray: boolean;
202
+ /** Nesting level. */
203
+ depth: number;
204
+ /** The containers above it. */
205
+ ancestors: readonly object[];
206
+ /** Rendering limits. */
207
+ limits: ViewLimits;
208
+ }
209
+
210
+ /** An object or array: a disclosure button, and its children once expanded. */
211
+ function JsonContainer({
212
+ nameNode,
213
+ value,
214
+ isArray,
215
+ depth,
216
+ ancestors,
217
+ limits,
218
+ }: JsonContainerProps) {
219
+ const [expanded, setExpanded] = useState(depth < limits.expandDepth);
220
+ const [shown, setShown] = useState(limits.maxChildren);
221
+ // Object.keys is O(n) but cheap next to rendering; children are only
222
+ // materialised while expanded.
223
+ const size = isArray
224
+ ? (value as unknown[]).length
225
+ : Object.keys(value).length;
226
+ const open = isArray ? "[" : "{";
227
+ const close = isArray ? "]" : "}";
228
+ const summary = isArray
229
+ ? `${size} ${size === 1 ? "item" : "items"}`
230
+ : `${size} ${size === 1 ? "key" : "keys"}`;
231
+
232
+ if (size === 0) {
233
+ return (
234
+ <li className="json-row">
235
+ {nameNode}
236
+ <span className="json-punct">
237
+ {open}
238
+ {close}
239
+ </span>
240
+ </li>
241
+ );
242
+ }
243
+
244
+ const children = expanded ? entriesOf(value, shown) : [];
245
+ const nextAncestors = expanded ? [...ancestors, value] : ancestors;
246
+ return (
247
+ <li className="json-row json-container">
248
+ <button
249
+ type="button"
250
+ className="json-toggle"
251
+ aria-expanded={expanded}
252
+ onClick={() => setExpanded((current) => !current)}
253
+ >
254
+ <span
255
+ className="json-caret"
256
+ aria-hidden="true"
257
+ />
258
+ {nameNode}
259
+ <span className="json-punct">{open}</span>
260
+ {!expanded && (
261
+ <>
262
+ <span className="json-summary">{summary}</span>
263
+ <span className="json-punct">{close}</span>
264
+ </>
265
+ )}
266
+ </button>
267
+ {expanded && (
268
+ <>
269
+ <ul className="json-children">
270
+ {children.map(([key, item]) => (
271
+ <JsonNode
272
+ key={key}
273
+ name={key}
274
+ value={item}
275
+ depth={depth + 1}
276
+ ancestors={nextAncestors}
277
+ limits={limits}
278
+ />
279
+ ))}
280
+ {size > shown && (
281
+ <li className="json-row">
282
+ <button
283
+ type="button"
284
+ className="json-more"
285
+ onClick={() =>
286
+ setShown((current) => current + limits.maxChildren)
287
+ }
288
+ >
289
+ Show {Math.min(limits.maxChildren, size - shown)} more (
290
+ {size - shown} hidden)
291
+ </button>
292
+ </li>
293
+ )}
294
+ </ul>
295
+ <span className="json-punct">{close}</span>
296
+ </>
297
+ )}
298
+ </li>
299
+ );
300
+ }
301
+
302
+ /** Props of {@link JsonScalar}. */
303
+ interface JsonScalarProps {
304
+ /** The value. */
305
+ value: unknown;
306
+ /** Its kind. */
307
+ kind: Kind;
308
+ /** String length before truncation. */
309
+ maxLength: number;
310
+ }
311
+
312
+ /** A leaf value, colour-coded; long strings truncate with "show more". */
313
+ function JsonScalar({ value, kind, maxLength }: JsonScalarProps) {
314
+ const [full, setFull] = useState(false);
315
+ if (kind === "string") {
316
+ const text = value as string;
317
+ const cut = !full && text.length > maxLength;
318
+ return (
319
+ <>
320
+ <span className="json-string">
321
+ &quot;{cut ? text.slice(0, maxLength) : text}
322
+ {cut ? "…" : ""}&quot;
323
+ </span>
324
+ {text.length > maxLength && (
325
+ <button
326
+ type="button"
327
+ className="json-more"
328
+ aria-expanded={full}
329
+ onClick={() => setFull((current) => !current)}
330
+ >
331
+ {full ? "Show less" : `Show more (${text.length} chars)`}
332
+ </button>
333
+ )}
334
+ </>
335
+ );
336
+ }
337
+ return <span className={`json-${kind}`}>{scalarText(value)}</span>;
338
+ }