@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,58 @@
1
+ import type { Root } from "react-dom/client";
2
+ import type { DocsUiConfig } from "../../lib/docs/browser/config.ts";
3
+ import { StrictMode } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { App } from "./App";
6
+ import { readDocsUiConfig } from "./config";
7
+ import { createQueryClient } from "./queryClient";
8
+ import { applyTheme, initialTheme } from "./theme";
9
+
10
+ /** Options of {@link boot}. */
11
+ export interface BootOptions {
12
+ /** The document to read the config from and render into. Defaults to `document`. */
13
+ doc?: Document;
14
+ /** Retry failed reads. Defaults to `true`; tests pass `false`. */
15
+ retry?: boolean;
16
+ }
17
+
18
+ /** What {@link boot} started. */
19
+ export interface BootResult {
20
+ /** The config it read. */
21
+ config: DocsUiConfig;
22
+ /** The React root; `unmount()` tears the app down. */
23
+ root: Root;
24
+ }
25
+
26
+ /**
27
+ * Starts the viewer: reads the injected config, applies the theme, and renders
28
+ * into `#root`. A missing or malformed config renders a plain message
29
+ * instead (there is no document to fetch without it) and rethrows.
30
+ */
31
+ export function boot(options: BootOptions = {}): BootResult {
32
+ const doc = options.doc ?? document;
33
+ const container = doc.getElementById("root");
34
+ if (!container) {
35
+ throw new Error("The page has no #root element.");
36
+ }
37
+ let config: DocsUiConfig;
38
+ try {
39
+ config = readDocsUiConfig(doc);
40
+ } catch (error) {
41
+ container.textContent = `The API docs viewer could not start: ${error instanceof Error ? error.message : String(error)}`;
42
+ throw error;
43
+ }
44
+ applyTheme(initialTheme(config.theme), doc.documentElement);
45
+ doc.title = config.title;
46
+
47
+ const queryClient = createQueryClient({ retry: options.retry });
48
+ const root = createRoot(container);
49
+ root.render(
50
+ <StrictMode>
51
+ <App
52
+ config={config}
53
+ queryClient={queryClient}
54
+ />
55
+ </StrictMode>,
56
+ );
57
+ return { config, root };
58
+ }
@@ -0,0 +1,65 @@
1
+ import type { ReactNode } from "react";
2
+ import { cx } from "./classNames";
3
+
4
+ /** Colour of a {@link Badge}. */
5
+ export type BadgeTone =
6
+ "neutral" | "info" | "success" | "warning" | "danger" | "accent";
7
+
8
+ /** Props of {@link Badge}. */
9
+ export interface BadgeProps {
10
+ /** Colour. Defaults to `"neutral"`. */
11
+ tone?: BadgeTone;
12
+ /** A tooltip / longer explanation. */
13
+ title?: string;
14
+ /** Extra class names. */
15
+ className?: string;
16
+ /**
17
+ * `data-testid` for the badge, when a test or an example must find this one
18
+ * among several — by what it is, rather than by its position.
19
+ */
20
+ testId?: string;
21
+ /** The text. */
22
+ children: ReactNode;
23
+ }
24
+
25
+ /** A small status label. */
26
+ export function Badge({
27
+ tone = "neutral",
28
+ title,
29
+ className,
30
+ testId,
31
+ children,
32
+ }: BadgeProps) {
33
+ return (
34
+ <span
35
+ className={cx("badge", `badge-${tone}`, className)}
36
+ title={title}
37
+ data-testid={testId}
38
+ >
39
+ {children}
40
+ </span>
41
+ );
42
+ }
43
+
44
+ /** Props of {@link Chip}. */
45
+ export interface ChipProps {
46
+ /** What the value is, e.g. `"namespace"`. */
47
+ label: string;
48
+ /** The value. */
49
+ value: ReactNode;
50
+ /** A tooltip. */
51
+ title?: string;
52
+ }
53
+
54
+ /** A label/value pair, e.g. `namespace orders`. */
55
+ export function Chip({ label, value, title }: ChipProps) {
56
+ return (
57
+ <span
58
+ className="chip"
59
+ title={title}
60
+ >
61
+ <span className="chip-label">{label}</span>
62
+ <span className="chip-value">{value}</span>
63
+ </span>
64
+ );
65
+ }
@@ -0,0 +1,37 @@
1
+ import type { ButtonHTMLAttributes, Ref } from "react";
2
+ import { cx } from "./classNames";
3
+
4
+ /** Visual weight of a {@link Button}. */
5
+ export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
6
+
7
+ /** Props of {@link Button}. */
8
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
9
+ /** Visual weight. Defaults to `"secondary"`. */
10
+ variant?: ButtonVariant;
11
+ /** Size. Defaults to `"md"`. */
12
+ size?: "sm" | "md";
13
+ /** The underlying `<button>`, e.g. for a dialog's initial focus. */
14
+ ref?: Ref<HTMLButtonElement>;
15
+ }
16
+
17
+ /** A button; `type="button"` unless told otherwise, so it never submits a form by accident. */
18
+ export function Button({
19
+ variant = "secondary",
20
+ size = "md",
21
+ type = "button",
22
+ className,
23
+ ...rest
24
+ }: ButtonProps) {
25
+ return (
26
+ <button
27
+ {...rest}
28
+ type={type}
29
+ className={cx(
30
+ "btn",
31
+ `btn-${variant}`,
32
+ size === "sm" && "btn-sm",
33
+ className,
34
+ )}
35
+ />
36
+ );
37
+ }
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from "react";
2
+ import { useId } from "react";
3
+ import { cx } from "./classNames";
4
+
5
+ /** Props of {@link Card}. */
6
+ export interface CardProps {
7
+ /** Heading, rendered as an `<h2>` that labels the card's region. */
8
+ title?: ReactNode;
9
+ /** Controls shown beside the heading. */
10
+ actions?: ReactNode;
11
+ /** Extra class names. */
12
+ className?: string;
13
+ /** The content. */
14
+ children: ReactNode;
15
+ }
16
+
17
+ /** A bordered surface; with a `title` it is a labelled `<section>`. */
18
+ export function Card({ title, actions, className, children }: CardProps) {
19
+ const headingId = useId();
20
+ return (
21
+ <section
22
+ className={cx("card", className)}
23
+ aria-labelledby={title ? headingId : undefined}
24
+ >
25
+ {(title || actions) && (
26
+ <div className="card-header">
27
+ {title && (
28
+ <h2
29
+ id={headingId}
30
+ className="card-title"
31
+ >
32
+ {title}
33
+ </h2>
34
+ )}
35
+ {actions && <div className="card-actions">{actions}</div>}
36
+ </div>
37
+ )}
38
+ <div className="card-body">{children}</div>
39
+ </section>
40
+ );
41
+ }
@@ -0,0 +1,173 @@
1
+ import type { FormEvent, ReactNode } from "react";
2
+ import { useRef, useState } from "react";
3
+ import { Button } from "./Button";
4
+ import { Dialog } from "./Dialog";
5
+ import { Field } from "./Field";
6
+ import { TextInput } from "./inputs";
7
+ import { ProblemBanner } from "./ProblemBanner";
8
+
9
+ /** Props of {@link ConfirmDialog}. */
10
+ export interface ConfirmDialogProps {
11
+ /** Whether it is showing. State (typed text, error) resets on each opening. */
12
+ open: boolean;
13
+ /** Asks to close (Cancel, Escape, backdrop, or after a successful confirm). Ignored while pending. */
14
+ onClose: () => void;
15
+ /** The question, e.g. "Drain queue emails?". */
16
+ title: ReactNode;
17
+ /** What will happen, in a sentence. */
18
+ description?: ReactNode;
19
+ /** Extra body content, e.g. options such as a "include delayed" checkbox. */
20
+ children?: ReactNode;
21
+ /**
22
+ * The action. May be async: the dialog shows a pending state until it
23
+ * settles, closes on success, and on a throw stays open with the error
24
+ * shown inline (an `ApiError` as a {@link ProblemBanner}).
25
+ */
26
+ onConfirm: () => unknown;
27
+ /** The confirm button's text. Defaults to `"Confirm"`. */
28
+ confirmLabel?: string;
29
+ /** The confirm button's text while pending. Defaults to `confirmLabel` + "…". */
30
+ pendingLabel?: string;
31
+ /** The cancel button's text. Defaults to `"Cancel"`. */
32
+ cancelLabel?: string;
33
+ /** `"danger"` for destructive actions: a red button, `alertdialog`, and Cancel focused first. Defaults to `"default"`. */
34
+ variant?: "default" | "danger";
35
+ /** When set, the user must type exactly this (e.g. the queue name) before Confirm enables. */
36
+ confirmText?: string;
37
+ /** The typed-confirmation label. Defaults to "Type <confirmText> to confirm". */
38
+ confirmTextLabel?: ReactNode;
39
+ /** Close after a successful `onConfirm`. Defaults to `true`. */
40
+ closeOnSuccess?: boolean;
41
+ /** Keeps Confirm disabled, e.g. while the body's options are invalid. */
42
+ confirmDisabled?: boolean;
43
+ }
44
+
45
+ /**
46
+ * A yes/no dialog for an action, built on {@link Dialog}: an optional typed
47
+ * confirmation for destructive ones, an async `onConfirm` with a pending
48
+ * state, and the failure shown in place so the user can retry or cancel.
49
+ */
50
+ export function ConfirmDialog(props: ConfirmDialogProps) {
51
+ return props.open ? <OpenConfirmDialog {...props} /> : null;
52
+ }
53
+
54
+ /** The mounted dialog; its state lives exactly as long as one opening. */
55
+ function OpenConfirmDialog({
56
+ onClose,
57
+ title,
58
+ description,
59
+ children,
60
+ onConfirm,
61
+ confirmLabel = "Confirm",
62
+ pendingLabel,
63
+ cancelLabel = "Cancel",
64
+ variant = "default",
65
+ confirmText,
66
+ confirmTextLabel,
67
+ closeOnSuccess = true,
68
+ confirmDisabled = false,
69
+ }: ConfirmDialogProps) {
70
+ const [typed, setTyped] = useState("");
71
+ const [pending, setPending] = useState(false);
72
+ const [error, setError] = useState<unknown>(null);
73
+ const cancelRef = useRef<HTMLButtonElement>(null);
74
+ const confirmRef = useRef<HTMLButtonElement>(null);
75
+ const inputRef = useRef<HTMLInputElement>(null);
76
+
77
+ const matches = confirmText === undefined || typed === confirmText;
78
+ const canConfirm = matches && !pending && !confirmDisabled;
79
+ const danger = variant === "danger";
80
+
81
+ const confirm = async () => {
82
+ if (!canConfirm) {
83
+ return;
84
+ }
85
+ setPending(true);
86
+ setError(null);
87
+ try {
88
+ await onConfirm();
89
+ setPending(false);
90
+ if (closeOnSuccess) {
91
+ onClose();
92
+ }
93
+ } catch (caught) {
94
+ setPending(false);
95
+ setError(caught);
96
+ }
97
+ };
98
+
99
+ const onSubmit = (event: FormEvent<HTMLFormElement>) => {
100
+ event.preventDefault();
101
+ void confirm();
102
+ };
103
+
104
+ const close = () => {
105
+ if (!pending) {
106
+ onClose();
107
+ }
108
+ };
109
+
110
+ return (
111
+ <Dialog
112
+ open
113
+ onClose={close}
114
+ title={title}
115
+ description={description}
116
+ role={danger ? "alertdialog" : "dialog"}
117
+ size="sm"
118
+ closeOnEscape={!pending}
119
+ closeOnBackdrop={!pending}
120
+ initialFocusRef={
121
+ confirmText !== undefined ? inputRef : danger ? cancelRef : confirmRef
122
+ }
123
+ footer={
124
+ <>
125
+ <Button
126
+ ref={cancelRef}
127
+ onClick={close}
128
+ disabled={pending}
129
+ >
130
+ {cancelLabel}
131
+ </Button>
132
+ <Button
133
+ ref={confirmRef}
134
+ variant={danger ? "danger" : "primary"}
135
+ disabled={!canConfirm}
136
+ aria-busy={pending || undefined}
137
+ onClick={() => void confirm()}
138
+ >
139
+ {pending ? (pendingLabel ?? `${confirmLabel}…`) : confirmLabel}
140
+ </Button>
141
+ </>
142
+ }
143
+ >
144
+ <form
145
+ className="confirm-dialog-body"
146
+ onSubmit={onSubmit}
147
+ >
148
+ {children}
149
+ {confirmText !== undefined && (
150
+ <Field
151
+ label={
152
+ confirmTextLabel ?? (
153
+ <>
154
+ Type <code>{confirmText}</code> to confirm
155
+ </>
156
+ )
157
+ }
158
+ >
159
+ <TextInput
160
+ ref={inputRef}
161
+ value={typed}
162
+ onChange={setTyped}
163
+ autoComplete="off"
164
+ spellCheck={false}
165
+ disabled={pending}
166
+ />
167
+ </Field>
168
+ )}
169
+ {error !== null && <ProblemBanner error={error} />}
170
+ </form>
171
+ </Dialog>
172
+ );
173
+ }
@@ -0,0 +1,68 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Button } from "./Button";
3
+ import { cx } from "./classNames";
4
+ import { COPY_FEEDBACK_MS, copyText } from "./copy";
5
+
6
+ /** Props of {@link CopyButton}. */
7
+ export interface CopyButtonProps {
8
+ /** What to copy, or a function producing it on click (so a large payload is only serialised when asked). */
9
+ text: string | (() => string);
10
+ /** The button's visible text. Defaults to `"Copy"`. */
11
+ label?: string;
12
+ /** Accessible name when it differs from `label`, e.g. `"Copy job id"`. */
13
+ ariaLabel?: string;
14
+ /** Size. Defaults to `"sm"`. */
15
+ size?: "sm" | "md";
16
+ /** Extra class names. */
17
+ className?: string;
18
+ /** Called after each attempt with whether the clipboard write succeeded. */
19
+ onCopied?: (ok: boolean) => void;
20
+ }
21
+
22
+ /**
23
+ * Copies text to the clipboard and says so: the label turns into "Copied"
24
+ * (or "Copy failed") for {@link COPY_FEEDBACK_MS}, announced politely.
25
+ */
26
+ export function CopyButton({
27
+ text,
28
+ label = "Copy",
29
+ ariaLabel,
30
+ size = "sm",
31
+ className,
32
+ onCopied,
33
+ }: CopyButtonProps) {
34
+ const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle");
35
+
36
+ useEffect(() => {
37
+ if (status === "idle") {
38
+ return;
39
+ }
40
+ const timer = setTimeout(setStatus, COPY_FEEDBACK_MS, "idle");
41
+ return () => clearTimeout(timer);
42
+ }, [status]);
43
+
44
+ const onClick = async () => {
45
+ const ok = await copyText(typeof text === "function" ? text() : text);
46
+ setStatus(ok ? "copied" : "failed");
47
+ onCopied?.(ok);
48
+ };
49
+
50
+ return (
51
+ <Button
52
+ variant="ghost"
53
+ size={size}
54
+ className={cx("copy-button", className)}
55
+ aria-label={ariaLabel}
56
+ data-status={status}
57
+ onClick={() => void onClick()}
58
+ >
59
+ <span aria-live="polite">
60
+ {status === "copied"
61
+ ? "Copied"
62
+ : status === "failed"
63
+ ? "Copy failed"
64
+ : label}
65
+ </span>
66
+ </Button>
67
+ );
68
+ }
@@ -0,0 +1,230 @@
1
+ import type {
2
+ KeyboardEvent,
3
+ MouseEvent,
4
+ ReactNode,
5
+ RefObject,
6
+ SyntheticEvent,
7
+ } from "react";
8
+ import { useEffect, useId, useRef } from "react";
9
+ import { Button } from "./Button";
10
+ import { cx } from "./classNames";
11
+
12
+ /** What counts as focusable inside a dialog, for the trap and the initial focus. */
13
+ const FOCUSABLE = [
14
+ "a[href]",
15
+ "button:not([disabled])",
16
+ "input:not([disabled]):not([type='hidden'])",
17
+ "select:not([disabled])",
18
+ "textarea:not([disabled])",
19
+ "summary",
20
+ "[contenteditable]:not([contenteditable='false'])",
21
+ "[tabindex]:not([tabindex='-1'])",
22
+ ].join(",");
23
+
24
+ /** The focusable elements inside `root`, in DOM order. */
25
+ function focusableIn(root: HTMLElement): HTMLElement[] {
26
+ return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
27
+ (element) => !element.hasAttribute("inert") && !element.closest("[inert]"),
28
+ );
29
+ }
30
+
31
+ /** Props of {@link Dialog}. */
32
+ export interface DialogProps {
33
+ /** Whether the dialog is showing. The content only mounts while open, so its state resets on each opening. */
34
+ open: boolean;
35
+ /** Asks to close: Escape, a backdrop click or the close button. The caller sets `open` to `false`. */
36
+ onClose: () => void;
37
+ /** The heading; it labels the dialog (`aria-labelledby`). */
38
+ title: ReactNode;
39
+ /** A sentence under the title; it describes the dialog (`aria-describedby`). */
40
+ description?: ReactNode;
41
+ /** The body. */
42
+ children?: ReactNode;
43
+ /** Actions along the bottom, usually {@link Button}s; the primary one last. */
44
+ footer?: ReactNode;
45
+ /** Close on Escape. Defaults to `true`; turn off while something is pending. */
46
+ closeOnEscape?: boolean;
47
+ /** Close on a click outside the panel. Defaults to `true`. */
48
+ closeOnBackdrop?: boolean;
49
+ /** Show the × button in the header. Defaults to `true`. */
50
+ showCloseButton?: boolean;
51
+ /** Width. Defaults to `"md"` (`sm` 400px, `md` 560px, `lg` 800px, each capped at the viewport). */
52
+ size?: "sm" | "md" | "lg";
53
+ /** What to focus on open. Defaults to the first focusable element in the body, then the footer, then the panel. */
54
+ initialFocusRef?: RefObject<HTMLElement | null>;
55
+ /** `"alertdialog"` for a confirmation that interrupts. Defaults to `"dialog"`. */
56
+ role?: "dialog" | "alertdialog";
57
+ /** Extra class names on the panel. */
58
+ className?: string;
59
+ }
60
+
61
+ /**
62
+ * A modal on the native `<dialog>` element (`showModal()`, so the page
63
+ * behind is inert and it sits in the top layer; no portal needed).
64
+ *
65
+ * Tab and Shift+Tab cycle inside it; Escape and a backdrop click call
66
+ * `onClose` when allowed; on close, focus returns to whatever had it when
67
+ * the dialog opened.
68
+ */
69
+ export function Dialog(props: DialogProps) {
70
+ return props.open ? <OpenDialog {...props} /> : null;
71
+ }
72
+
73
+ /** The mounted dialog: mount is "open", unmount is "close". */
74
+ function OpenDialog({
75
+ onClose,
76
+ title,
77
+ description,
78
+ children,
79
+ footer,
80
+ closeOnEscape = true,
81
+ closeOnBackdrop = true,
82
+ showCloseButton = true,
83
+ size = "md",
84
+ initialFocusRef,
85
+ role = "dialog",
86
+ className,
87
+ }: DialogProps) {
88
+ const ref = useRef<HTMLDialogElement>(null);
89
+ const bodyRef = useRef<HTMLDivElement>(null);
90
+ const titleId = useId();
91
+ const descriptionId = useId();
92
+
93
+ useEffect(() => {
94
+ const dialog = ref.current;
95
+ if (!dialog) {
96
+ return;
97
+ }
98
+ const opener =
99
+ document.activeElement instanceof HTMLElement
100
+ ? document.activeElement
101
+ : null;
102
+ if (!dialog.open) {
103
+ if (typeof dialog.showModal === "function") {
104
+ dialog.showModal();
105
+ } else {
106
+ dialog.setAttribute("open", "");
107
+ }
108
+ }
109
+ const target =
110
+ initialFocusRef?.current ??
111
+ (bodyRef.current && focusableIn(bodyRef.current)[0]) ??
112
+ focusableIn(dialog).find((element) => !element.dataset.dialogClose) ??
113
+ dialog;
114
+ target.focus();
115
+ return () => {
116
+ if (dialog.open && typeof dialog.close === "function") {
117
+ dialog.close();
118
+ }
119
+ if (opener?.isConnected) {
120
+ opener.focus();
121
+ }
122
+ };
123
+ // Runs once per opening (the ref objects are stable).
124
+ }, [initialFocusRef]);
125
+
126
+ const onKeyDown = (event: KeyboardEvent<HTMLDialogElement>) => {
127
+ if (event.key === "Escape") {
128
+ // Stop the native `cancel` so the element's open state stays ours.
129
+ event.preventDefault();
130
+ event.stopPropagation();
131
+ if (closeOnEscape) {
132
+ onClose();
133
+ }
134
+ return;
135
+ }
136
+ if (event.key !== "Tab" || !ref.current) {
137
+ return;
138
+ }
139
+ const focusable = focusableIn(ref.current);
140
+ if (focusable.length === 0) {
141
+ event.preventDefault();
142
+ ref.current.focus();
143
+ return;
144
+ }
145
+ const first = focusable[0];
146
+ const last = focusable[focusable.length - 1];
147
+ const active = document.activeElement;
148
+ const inside = active instanceof Node && ref.current.contains(active);
149
+ if (event.shiftKey && (active === first || !inside)) {
150
+ event.preventDefault();
151
+ last.focus();
152
+ } else if (!event.shiftKey && (active === last || !inside)) {
153
+ event.preventDefault();
154
+ first.focus();
155
+ }
156
+ };
157
+
158
+ // Escape in a real browser can arrive as `cancel` without a keydown we saw.
159
+ const onCancel = (event: SyntheticEvent<HTMLDialogElement>) => {
160
+ event.preventDefault();
161
+ if (closeOnEscape) {
162
+ onClose();
163
+ }
164
+ };
165
+
166
+ // The panel fills the element, so a click whose target is the element
167
+ // itself landed on the backdrop.
168
+ const onClick = (event: MouseEvent<HTMLDialogElement>) => {
169
+ if (closeOnBackdrop && event.target === event.currentTarget) {
170
+ onClose();
171
+ }
172
+ };
173
+
174
+ return (
175
+ // The keyboard handling lives on the dialog element (Escape and the
176
+ // focus trap); a backdrop click is a pointer-only convenience.
177
+ <dialog
178
+ ref={ref}
179
+ role={role === "alertdialog" ? "alertdialog" : undefined}
180
+ aria-modal="true"
181
+ aria-labelledby={titleId}
182
+ aria-describedby={description ? descriptionId : undefined}
183
+ className={cx("dialog", `dialog-${size}`, className)}
184
+ tabIndex={-1}
185
+ onKeyDown={onKeyDown}
186
+ onCancel={onCancel}
187
+ onClick={onClick}
188
+ >
189
+ <div className="dialog-panel">
190
+ <div className="dialog-header">
191
+ <h2
192
+ id={titleId}
193
+ className="dialog-title"
194
+ >
195
+ {title}
196
+ </h2>
197
+ {showCloseButton && (
198
+ <Button
199
+ variant="ghost"
200
+ size="sm"
201
+ className="dialog-close"
202
+ aria-label="Close"
203
+ data-dialog-close="true"
204
+ onClick={onClose}
205
+ >
206
+ <span aria-hidden="true">×</span>
207
+ </Button>
208
+ )}
209
+ </div>
210
+ {description && (
211
+ <p
212
+ id={descriptionId}
213
+ className="dialog-description"
214
+ >
215
+ {description}
216
+ </p>
217
+ )}
218
+ {children !== undefined && (
219
+ <div
220
+ ref={bodyRef}
221
+ className="dialog-body"
222
+ >
223
+ {children}
224
+ </div>
225
+ )}
226
+ {footer && <div className="dialog-footer">{footer}</div>}
227
+ </div>
228
+ </dialog>
229
+ );
230
+ }