@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,63 @@
1
+ import type { ReactNode } from "react";
2
+ import { cx } from "./classNames";
3
+
4
+ /** One row of a {@link KeyValue}. */
5
+ export interface KeyValueItem {
6
+ /** The term, e.g. "Attempts". */
7
+ label: ReactNode;
8
+ /** The value: text or any node. `null`, `undefined` and `""` render the `empty` placeholder. */
9
+ value: ReactNode;
10
+ /** A stable React key; defaults to the label when it is a string, else the index. */
11
+ key?: string;
12
+ /** A short note under the value, e.g. "of 5 allowed". */
13
+ hint?: ReactNode;
14
+ }
15
+
16
+ /** Props of {@link KeyValue}. */
17
+ export interface KeyValueProps {
18
+ /** The rows, in order. Falsy entries are skipped, so rows can be conditional inline. */
19
+ items: readonly (KeyValueItem | false | null | undefined)[];
20
+ /** `"grid"` puts label and value side by side; `"stacked"` puts the label above. Defaults to `"grid"`. */
21
+ layout?: "grid" | "stacked";
22
+ /** What a missing value shows. Defaults to `"—"`. */
23
+ empty?: ReactNode;
24
+ /** Extra class names on the `<dl>`. */
25
+ className?: string;
26
+ }
27
+
28
+ /** A summary as a definition list: label/value rows (a job's timings, a queue's settings). */
29
+ export function KeyValue({
30
+ items,
31
+ layout = "grid",
32
+ empty = "—",
33
+ className,
34
+ }: KeyValueProps) {
35
+ return (
36
+ <dl className={cx("kv", `kv-${layout}`, className)}>
37
+ {items.map((item, index) => {
38
+ if (!item) {
39
+ return null;
40
+ }
41
+ const missing =
42
+ item.value === null || item.value === undefined || item.value === "";
43
+ // The index is only the fallback for a row with neither a key nor a
44
+ // string label.
45
+ const rowKey =
46
+ item.key ??
47
+ (typeof item.label === "string" ? item.label : `row-${index}`);
48
+ return (
49
+ <div
50
+ key={rowKey}
51
+ className="kv-row"
52
+ >
53
+ <dt className="kv-label">{item.label}</dt>
54
+ <dd className={cx("kv-value", missing && "muted")}>
55
+ {missing ? empty : item.value}
56
+ {item.hint && <span className="kv-hint">{item.hint}</span>}
57
+ </dd>
58
+ </div>
59
+ );
60
+ })}
61
+ </dl>
62
+ );
63
+ }
@@ -0,0 +1,116 @@
1
+ import { isApiError } from "../api/errors";
2
+ import { Button } from "./Button";
3
+ import { cx } from "./classNames";
4
+
5
+ /** Props of {@link ProblemBanner}. */
6
+ export interface ProblemBannerProps {
7
+ /** What failed: an `ApiError` shows its title, code, detail, context and issues; anything else its message. */
8
+ error: unknown;
9
+ /** A heading override, e.g. "Could not save limits". Defaults to the problem's title. */
10
+ title?: string;
11
+ /** Offers a Retry button when given. */
12
+ onRetry?: () => void;
13
+ /** Offers a Dismiss button when given. */
14
+ onDismiss?: () => void;
15
+ /** Extra class names. */
16
+ className?: string;
17
+ }
18
+
19
+ /** Formats one context value for the banner: strings as-is, the rest as JSON. */
20
+ function contextText(value: unknown): string {
21
+ if (typeof value === "string") {
22
+ return value;
23
+ }
24
+ try {
25
+ return JSON.stringify(value) ?? String(value);
26
+ } catch {
27
+ return String(value);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * A compact, inline error for a panel or a dialog: one line of title and
33
+ * code, the detail, the problem's `context` as `key=value` chips, and any
34
+ * validation issues. {@link ErrorView} is the full-size version for a screen.
35
+ */
36
+ export function ProblemBanner({
37
+ error,
38
+ title,
39
+ onRetry,
40
+ onDismiss,
41
+ className,
42
+ }: ProblemBannerProps) {
43
+ const api = isApiError(error) ? error : null;
44
+ const heading = title ?? api?.title ?? "Something went wrong";
45
+ const detail =
46
+ api?.detail ?? (error instanceof Error ? error.message : String(error));
47
+ const context = api ? Object.entries(api.context) : [];
48
+ return (
49
+ <div
50
+ className={cx("problem-banner", className)}
51
+ role="alert"
52
+ >
53
+ <div className="problem-banner-main">
54
+ <p className="problem-banner-title">
55
+ <strong>{heading}</strong>
56
+ {api && (
57
+ <code className="problem-banner-code">
58
+ {api.code}
59
+ {api.status > 0 ? ` · ${api.status}` : ""}
60
+ </code>
61
+ )}
62
+ </p>
63
+ {detail && detail !== heading && (
64
+ <p className="problem-banner-detail">{detail}</p>
65
+ )}
66
+ {context.length > 0 && (
67
+ <ul
68
+ className="problem-banner-context"
69
+ aria-label="Context"
70
+ >
71
+ {context.map(([key, value]) => (
72
+ <li key={key}>
73
+ <span className="problem-banner-key">{key}</span>=
74
+ <code>{contextText(value)}</code>
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ )}
79
+ {api && api.issues.length > 0 && (
80
+ <ul className="problem-banner-issues">
81
+ {api.issues.map((issue) => (
82
+ <li key={`${issue.target}:${issue.path}:${issue.message}`}>
83
+ <code>
84
+ {issue.target}
85
+ {issue.path ? `.${issue.path}` : ""}
86
+ </code>{" "}
87
+ {issue.message}
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ )}
92
+ </div>
93
+ {(onRetry || onDismiss) && (
94
+ <div className="problem-banner-actions">
95
+ {onRetry && (
96
+ <Button
97
+ size="sm"
98
+ onClick={onRetry}
99
+ >
100
+ Retry
101
+ </Button>
102
+ )}
103
+ {onDismiss && (
104
+ <Button
105
+ size="sm"
106
+ variant="ghost"
107
+ onClick={onDismiss}
108
+ >
109
+ Dismiss
110
+ </Button>
111
+ )}
112
+ </div>
113
+ )}
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,141 @@
1
+ import type { QueryClient } from "@tanstack/react-query";
2
+ import type { ErrorInfo, ReactNode } from "react";
3
+ import { QueryClientContext } from "@tanstack/react-query";
4
+ import { Component } from "react";
5
+ import { resetScreenQueries } from "../queryClient";
6
+ import { Button } from "./Button";
7
+
8
+ /** Props of {@link ScreenErrorBoundary}. */
9
+ export interface ScreenErrorBoundaryProps {
10
+ /** The screen. */
11
+ children: ReactNode;
12
+ /** When this changes (the location), a failed screen is tried again: navigating away from a crash recovers without a click. */
13
+ resetKey?: string;
14
+ /** Called with each crash, e.g. to log it. Defaults to nothing (React already reports it in development). */
15
+ onError?: (error: unknown, info: ErrorInfo) => void;
16
+ }
17
+
18
+ /** State of {@link ScreenErrorBoundary}. */
19
+ interface ScreenErrorBoundaryState {
20
+ /** What the screen threw while rendering, or `null` while it renders fine. */
21
+ error: unknown;
22
+ /** Whether {@link ScreenErrorBoundaryState.error} is set (an error may be any value, `null` included). */
23
+ failed: boolean;
24
+ /** Bumped by "Reload this screen", remounting the screen with fresh state. */
25
+ attempt: number;
26
+ /** The `resetKey` the failure happened under. */
27
+ failedKey: string | undefined;
28
+ }
29
+
30
+ /**
31
+ * Catches a routed screen's render crash so the app around it survives: the
32
+ * header, nav and live status stay, and the screen's place shows what
33
+ * failed with a "Reload this screen" button that mounts it afresh and
34
+ * re-reads its data: the cached answers it read are reset first (see
35
+ * {@link resetScreenQueries}; `/meta` and the permissions are kept), so a
36
+ * crash caused by a bad answer recovers once the host answers well. A lazy
37
+ * screen whose chunk failed to load is retried the same way (see
38
+ * `screens/lazy.tsx`).
39
+ *
40
+ * The app's queries do not throw into it (no `throwOnError`, no suspense
41
+ * queries: a failed read renders the screen's own error view), so there is
42
+ * no `QueryErrorResetBoundary` to reset.
43
+ */
44
+ export class ScreenErrorBoundary extends Component<
45
+ ScreenErrorBoundaryProps,
46
+ ScreenErrorBoundaryState
47
+ > {
48
+ static override contextType = QueryClientContext;
49
+
50
+ /** The app's query client, when a `QueryClientProvider` is above (none in some tests). */
51
+ declare context: QueryClient | undefined;
52
+
53
+ override state: ScreenErrorBoundaryState = {
54
+ error: null,
55
+ failed: false,
56
+ attempt: 0,
57
+ failedKey: undefined,
58
+ };
59
+
60
+ static getDerivedStateFromError(
61
+ error: unknown,
62
+ ): Partial<ScreenErrorBoundaryState> {
63
+ return { error, failed: true };
64
+ }
65
+
66
+ static getDerivedStateFromProps(
67
+ props: ScreenErrorBoundaryProps,
68
+ state: ScreenErrorBoundaryState,
69
+ ): Partial<ScreenErrorBoundaryState> | null {
70
+ // Remember where it failed; a new location clears the failure.
71
+ if (state.failed && state.failedKey === undefined) {
72
+ return { failedKey: props.resetKey ?? "" };
73
+ }
74
+ if (state.failed && state.failedKey !== (props.resetKey ?? "")) {
75
+ return {
76
+ error: null,
77
+ failed: false,
78
+ failedKey: undefined,
79
+ attempt: state.attempt + 1,
80
+ };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ override componentDidCatch(error: unknown, info: ErrorInfo): void {
86
+ this.props.onError?.(error, info);
87
+ }
88
+
89
+ /** Resets the screen's cached answers, then mounts it again. */
90
+ private readonly reload = () => {
91
+ if (this.context) {
92
+ void resetScreenQueries(this.context);
93
+ }
94
+ this.setState((state) => ({
95
+ error: null,
96
+ failed: false,
97
+ failedKey: undefined,
98
+ attempt: state.attempt + 1,
99
+ }));
100
+ };
101
+
102
+ override render(): ReactNode {
103
+ const { error, failed, attempt } = this.state;
104
+ if (!failed) {
105
+ return <ScreenAttempt key={attempt}>{this.props.children}</ScreenAttempt>;
106
+ }
107
+ const message =
108
+ error instanceof Error ? error.message : String(error ?? "Unknown error");
109
+ return (
110
+ <div
111
+ className="screen screen-error"
112
+ data-testid="screen-error"
113
+ >
114
+ <div
115
+ className="error-view"
116
+ role="alert"
117
+ >
118
+ <h1 className="error-view-title">This screen failed to show</h1>
119
+ <p className="error-view-detail">
120
+ Something went wrong while drawing it. The rest of the app still
121
+ works: reload the screen, or pick another one.
122
+ </p>
123
+ <p className="error-view-meta">
124
+ <code>{message}</code>
125
+ </p>
126
+ <Button
127
+ variant="primary"
128
+ onClick={this.reload}
129
+ >
130
+ Reload this screen
131
+ </Button>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
136
+ }
137
+
138
+ /** A keyed wrapper: a new key remounts the screen with fresh state. */
139
+ function ScreenAttempt({ children }: { children: ReactNode }) {
140
+ return <>{children}</>;
141
+ }
@@ -0,0 +1,33 @@
1
+ import { cx } from "./classNames";
2
+
3
+ /** Props of {@link Spinner}. */
4
+ export interface SpinnerProps {
5
+ /** What is loading; read by screen readers. Defaults to `"Loading"`. */
6
+ label?: string;
7
+ /** Show the label next to the spinner. Defaults to `false` (screen readers only). */
8
+ showLabel?: boolean;
9
+ /** Extra class names. */
10
+ className?: string;
11
+ }
12
+
13
+ /** A loading indicator with a live `status` role. */
14
+ export function Spinner({
15
+ label = "Loading",
16
+ showLabel = false,
17
+ className,
18
+ }: SpinnerProps) {
19
+ return (
20
+ <span
21
+ className={cx("spinner-wrap", className)}
22
+ role="status"
23
+ >
24
+ <span
25
+ className="spinner"
26
+ aria-hidden="true"
27
+ />
28
+ <span className={showLabel ? "spinner-label" : "visually-hidden"}>
29
+ {label}
30
+ </span>
31
+ </span>
32
+ );
33
+ }
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from "react";
2
+ import { cx } from "./classNames";
3
+
4
+ /** Props of {@link Table}. */
5
+ export interface TableProps {
6
+ /** Accessible name of the table (rendered as a visually hidden caption). */
7
+ label: string;
8
+ /** Extra class names on the `<table>`. */
9
+ className?: string;
10
+ /** `<thead>` / `<tbody>`. */
11
+ children: ReactNode;
12
+ }
13
+
14
+ /**
15
+ * A table in a scrollable, focusable region with a sticky header. Keyboard
16
+ * users can scroll a wide table because the region is tabbable.
17
+ */
18
+ export function Table({ label, className, children }: TableProps) {
19
+ return (
20
+ <div
21
+ className="table-wrap"
22
+ role="region"
23
+ aria-label={label}
24
+ // A scrollable region must be reachable by keyboard.
25
+ tabIndex={0}
26
+ >
27
+ <table className={cx("table", className)}>
28
+ <caption className="visually-hidden">{label}</caption>
29
+ {children}
30
+ </table>
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,226 @@
1
+ import type { KeyboardEvent, ReactNode } from "react";
2
+ import { useCallback, useId, useRef } from "react";
3
+ import { useUrlTab } from "../hooks/useUrlTab";
4
+ import { useRouter } from "../routing";
5
+ import { cx } from "./classNames";
6
+
7
+ /** One tab of {@link Tabs}. */
8
+ export interface TabItem<V extends string> {
9
+ /** The tab's value, e.g. a job state. */
10
+ value: V;
11
+ /** The text on the tab. */
12
+ label: ReactNode;
13
+ /** A count shown as a badge beside the label; omitted when `undefined`. */
14
+ count?: number;
15
+ /** Not selectable. */
16
+ disabled?: boolean;
17
+ /** A tooltip on the tab (its `title`), e.g. what a state's label means. Omitted when `undefined`. */
18
+ title?: string;
19
+ }
20
+
21
+ /** Props of {@link Tabs}. */
22
+ export interface TabsProps<V extends string> {
23
+ /** The tabs, in order. */
24
+ tabs: readonly TabItem<V>[];
25
+ /** The selected tab's value. */
26
+ value: V;
27
+ /** Called with the tab the user picked. */
28
+ onChange: (value: V) => void;
29
+ /** Accessible name of the tab list, e.g. "Job states". */
30
+ label: string;
31
+ /** The selected tab's content, rendered in the single tab panel. Omit to render only the tab list. */
32
+ children?: ReactNode;
33
+ /** `"auto"` selects on arrow-key focus; `"manual"` waits for Enter/Space. Defaults to `"auto"`. */
34
+ activation?: "auto" | "manual";
35
+ /** Extra class names on the wrapper. */
36
+ className?: string;
37
+ }
38
+
39
+ /** Formats a tab count compactly: `1234` → `1.2K`. */
40
+ const countFormat = new Intl.NumberFormat(undefined, { notation: "compact" });
41
+
42
+ /**
43
+ * Controlled tabs following the WAI-ARIA tabs pattern: `tablist`/`tab`/
44
+ * `tabpanel`, one tab in the tab order (roving `tabIndex`), Left/Right to
45
+ * move (wrapping), Home/End to jump. Disabled tabs are skipped.
46
+ */
47
+ export function Tabs<V extends string>({
48
+ tabs,
49
+ value,
50
+ onChange,
51
+ label,
52
+ children,
53
+ activation = "auto",
54
+ className,
55
+ }: TabsProps<V>) {
56
+ const baseId = useId();
57
+ const listRef = useRef<HTMLDivElement>(null);
58
+ const tabId = (index: number) => `${baseId}-tab-${index}`;
59
+ const panelId = `${baseId}-panel`;
60
+ const selectedIndex = tabs.findIndex((tab) => tab.value === value);
61
+
62
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
63
+ const enabled = tabs
64
+ .map((tab, index) => ({ tab, index }))
65
+ .filter(({ tab }) => !tab.disabled);
66
+ if (enabled.length === 0) {
67
+ return;
68
+ }
69
+ const current = enabled.findIndex(
70
+ ({ index }) => tabId(index) === event.currentTarget.id,
71
+ );
72
+ let next: number | null = null;
73
+ switch (event.key) {
74
+ case "ArrowRight":
75
+ next = (current + 1) % enabled.length;
76
+ break;
77
+ case "ArrowLeft":
78
+ next = (current - 1 + enabled.length) % enabled.length;
79
+ break;
80
+ case "Home":
81
+ next = 0;
82
+ break;
83
+ case "End":
84
+ next = enabled.length - 1;
85
+ break;
86
+ default:
87
+ return;
88
+ }
89
+ event.preventDefault();
90
+ const target = enabled[next];
91
+ listRef.current?.ownerDocument.getElementById(tabId(target.index))?.focus();
92
+ if (activation === "auto") {
93
+ onChange(target.tab.value);
94
+ }
95
+ };
96
+
97
+ return (
98
+ <div className={cx("tabs", className)}>
99
+ <div
100
+ ref={listRef}
101
+ className="tabs-list"
102
+ role="tablist"
103
+ aria-label={label}
104
+ >
105
+ {tabs.map((tab, index) => {
106
+ const selected = index === selectedIndex;
107
+ // With nothing selected, the first enabled tab takes the tab stop.
108
+ const tabStop =
109
+ selected ||
110
+ (selectedIndex === -1 &&
111
+ index === tabs.findIndex((item) => !item.disabled));
112
+ return (
113
+ <button
114
+ key={tab.value}
115
+ id={tabId(index)}
116
+ type="button"
117
+ role="tab"
118
+ className="tab"
119
+ aria-selected={selected}
120
+ aria-controls={children !== undefined ? panelId : undefined}
121
+ tabIndex={tabStop ? 0 : -1}
122
+ disabled={tab.disabled}
123
+ title={tab.title}
124
+ onClick={() => onChange(tab.value)}
125
+ onKeyDown={onKeyDown}
126
+ >
127
+ <span className="tab-label">{tab.label}</span>
128
+ {tab.count !== undefined && (
129
+ <span
130
+ className="tab-count"
131
+ title={String(tab.count)}
132
+ >
133
+ {countFormat.format(tab.count)}
134
+ </span>
135
+ )}
136
+ </button>
137
+ );
138
+ })}
139
+ </div>
140
+ {children !== undefined && (
141
+ <div
142
+ id={panelId}
143
+ className="tabs-panel"
144
+ role="tabpanel"
145
+ aria-labelledby={
146
+ selectedIndex >= 0 ? tabId(selectedIndex) : undefined
147
+ }
148
+ tabIndex={0}
149
+ >
150
+ {children}
151
+ </div>
152
+ )}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ /** Props of {@link UrlTabs}. */
158
+ export interface UrlTabsProps<V extends string> extends Omit<
159
+ TabsProps<V>,
160
+ "value" | "onChange"
161
+ > {
162
+ /** The query parameter holding the tab, e.g. `"state"`. */
163
+ param: string;
164
+ /** The tab when the parameter is absent or not one of `tabs`; selecting it removes the parameter. */
165
+ defaultValue: V;
166
+ /** Query parameters to remove when the tab changes, e.g. `["offset"]` so paging restarts. */
167
+ resetParams?: readonly string[];
168
+ /** Called after the URL changed. */
169
+ onChange?: (value: V) => void;
170
+ }
171
+
172
+ /**
173
+ * {@link Tabs} whose selection lives in the query string (`?state=failed`),
174
+ * so it survives reload and can be linked. Changes replace the history entry
175
+ * (the router's `navigate(…, { replace: true })`), keep every other
176
+ * parameter, and drop the `resetParams`.
177
+ */
178
+ export function UrlTabs<V extends string>({
179
+ param,
180
+ defaultValue,
181
+ resetParams = [],
182
+ onChange,
183
+ tabs,
184
+ ...rest
185
+ }: UrlTabsProps<V>) {
186
+ const { location, navigate } = useRouter();
187
+ const value = useUrlTab(param, tabs, defaultValue);
188
+ const select = useCallback(
189
+ (next: V) => {
190
+ const params = new URLSearchParams(location.search);
191
+ if (next === defaultValue) {
192
+ params.delete(param);
193
+ } else {
194
+ params.set(param, next);
195
+ }
196
+ if (next !== value) {
197
+ for (const key of resetParams) {
198
+ params.delete(key);
199
+ }
200
+ }
201
+ const query = params.toString();
202
+ navigate(`${location.path ?? "/"}${query ? `?${query}` : ""}`, {
203
+ replace: true,
204
+ });
205
+ onChange?.(next);
206
+ },
207
+ [
208
+ location.search,
209
+ location.path,
210
+ defaultValue,
211
+ param,
212
+ value,
213
+ resetParams,
214
+ navigate,
215
+ onChange,
216
+ ],
217
+ );
218
+ return (
219
+ <Tabs
220
+ {...rest}
221
+ tabs={tabs}
222
+ value={value}
223
+ onChange={select}
224
+ />
225
+ );
226
+ }
@@ -0,0 +1,4 @@
1
+ /** Joins class names, skipping falsy ones. */
2
+ export function cx(...names: (string | false | null | undefined)[]): string {
3
+ return names.filter(Boolean).join(" ");
4
+ }