@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.
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/app/docs/App.tsx +72 -0
- package/app/docs/api/client.ts +303 -0
- package/app/docs/api/contract.ts +24 -0
- package/app/docs/api/docs.ts +38 -0
- package/app/docs/api/errors.ts +144 -0
- package/app/docs/boot.tsx +58 -0
- package/app/docs/components/Badge.tsx +65 -0
- package/app/docs/components/Button.tsx +37 -0
- package/app/docs/components/Card.tsx +41 -0
- package/app/docs/components/ConfirmDialog.tsx +173 -0
- package/app/docs/components/CopyButton.tsx +68 -0
- package/app/docs/components/Dialog.tsx +230 -0
- package/app/docs/components/EmptyState.tsx +34 -0
- package/app/docs/components/ErrorView.tsx +70 -0
- package/app/docs/components/Field.tsx +93 -0
- package/app/docs/components/JsonEditor.tsx +151 -0
- package/app/docs/components/JsonView.tsx +338 -0
- package/app/docs/components/KeyValue.tsx +63 -0
- package/app/docs/components/ProblemBanner.tsx +116 -0
- package/app/docs/components/ScreenErrorBoundary.tsx +141 -0
- package/app/docs/components/Spinner.tsx +33 -0
- package/app/docs/components/Table.tsx +33 -0
- package/app/docs/components/Tabs.tsx +226 -0
- package/app/docs/components/classNames.ts +4 -0
- package/app/docs/components/components.css +691 -0
- package/app/docs/components/copy.ts +89 -0
- package/app/docs/components/fieldContext.ts +29 -0
- package/app/docs/components/inputValues.ts +142 -0
- package/app/docs/components/inputs.tsx +387 -0
- package/app/docs/components/jsonParse.ts +338 -0
- package/app/docs/components/listKeys.ts +30 -0
- package/app/docs/config.ts +86 -0
- package/app/docs/context.ts +33 -0
- package/app/docs/embed.ts +74 -0
- package/app/docs/env.d.ts +2 -0
- package/app/docs/format.ts +9 -0
- package/app/docs/hooks/useUrlTab.ts +16 -0
- package/app/docs/main.tsx +11 -0
- package/app/docs/queryClient.ts +54 -0
- package/app/docs/router.tsx +194 -0
- package/app/docs/routing.ts +194 -0
- package/app/docs/screens/docs/document.ts +27 -0
- package/app/docs/screens/docs/http/OperationView.tsx +407 -0
- package/app/docs/screens/docs/http/Sidebar.tsx +190 -0
- package/app/docs/screens/docs/http/TryItPanel.tsx +603 -0
- package/app/docs/screens/docs/http/http.css +405 -0
- package/app/docs/screens/docs/http/index.tsx +395 -0
- package/app/docs/screens/docs/http/model.ts +604 -0
- package/app/docs/screens/docs/http/tryIt.ts +455 -0
- package/app/docs/screens/docs/permissions.ts +6 -0
- package/app/docs/screens/docs/schema/Prose.tsx +43 -0
- package/app/docs/screens/docs/schema/SchemaTree.tsx +319 -0
- package/app/docs/screens/docs/schema/index.ts +30 -0
- package/app/docs/screens/docs/schema/resolve.ts +466 -0
- package/app/docs/screens/docs/schema/schema.css +137 -0
- package/app/docs/screens/docs/schema/skeleton.ts +92 -0
- package/app/docs/screens/docs/viewer.tsx +154 -0
- package/app/docs/screens/docs/ws/InlineText.tsx +25 -0
- package/app/docs/screens/docs/ws/PayloadSchema.tsx +39 -0
- package/app/docs/screens/docs/ws/format.ts +82 -0
- package/app/docs/screens/docs/ws/index.tsx +286 -0
- package/app/docs/screens/docs/ws/items.tsx +786 -0
- package/app/docs/screens/docs/ws/model.ts +1178 -0
- package/app/docs/screens/docs/ws/panels.tsx +440 -0
- package/app/docs/screens/docs/ws/ws.css +231 -0
- package/app/docs/shortcuts.ts +94 -0
- package/app/docs/styles/base.css +847 -0
- package/app/docs/styles/tokens.css +143 -0
- package/app/docs/theme.ts +56 -0
- package/app/kit/unauthorized.ts +222 -0
- package/dist/docs/assets/main-0rchxtv5.js +18 -0
- package/dist/docs/assets/main-0rchxtv5.js.map +99 -0
- package/dist/docs/assets/main-cqgyvs86.css +1 -0
- package/dist/docs/manifest.json +26 -0
- package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js +6 -0
- package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js.map +11 -0
- package/dist/jobs/assets/AddFlowDialog-n7gtc84v.css +1 -0
- package/dist/jobs/assets/added-cbwsxv3e.js +4 -0
- package/dist/jobs/assets/added-cbwsxv3e.js.map +11 -0
- package/dist/jobs/assets/bodies-yyw5hmrp.js +6 -0
- package/dist/jobs/assets/bodies-yyw5hmrp.js.map +10 -0
- package/dist/jobs/assets/channel-tgkvqm8y.js +4 -0
- package/dist/jobs/assets/channel-tgkvqm8y.js.map +10 -0
- package/dist/jobs/assets/channelChecks-s4yz4yhw.js +4 -0
- package/dist/jobs/assets/channelChecks-s4yz4yhw.js.map +10 -0
- package/dist/jobs/assets/index-02427smp.css +1 -0
- package/dist/jobs/assets/index-09z2cjft.js +4 -0
- package/dist/jobs/assets/index-09z2cjft.js.map +16 -0
- package/dist/jobs/assets/index-18bn0gvy.css +1 -0
- package/dist/jobs/assets/index-47pxmyxn.js +4 -0
- package/dist/jobs/assets/index-47pxmyxn.js.map +10 -0
- package/dist/jobs/assets/index-4e55z986.js +4 -0
- package/dist/jobs/assets/index-4e55z986.js.map +11 -0
- package/dist/jobs/assets/index-5c933424.js +4 -0
- package/dist/jobs/assets/index-5c933424.js.map +18 -0
- package/dist/jobs/assets/index-5kbyqspm.css +1 -0
- package/dist/jobs/assets/index-5ng37hk6.css +1 -0
- package/dist/jobs/assets/index-5w8w7fvc.css +1 -0
- package/dist/jobs/assets/index-71qs5qyx.css +1 -0
- package/dist/jobs/assets/index-btpg4r8b.css +1 -0
- package/dist/jobs/assets/index-d5r5n0mm.js +4 -0
- package/dist/jobs/assets/index-d5r5n0mm.js.map +23 -0
- package/dist/jobs/assets/index-e0yy2skq.css +1 -0
- package/dist/jobs/assets/index-hs60b25v.js +10 -0
- package/dist/jobs/assets/index-hs60b25v.js.map +62 -0
- package/dist/jobs/assets/index-k2smrr70.js +4 -0
- package/dist/jobs/assets/index-k2smrr70.js.map +9 -0
- package/dist/jobs/assets/index-q24jw3w9.js +4 -0
- package/dist/jobs/assets/index-q24jw3w9.js.map +32 -0
- package/dist/jobs/assets/index-qfb80xen.css +1 -0
- package/dist/jobs/assets/index-rtynx6wj.js +4 -0
- package/dist/jobs/assets/index-rtynx6wj.js.map +10 -0
- package/dist/jobs/assets/live-6k7s6358.js +4 -0
- package/dist/jobs/assets/live-6k7s6358.js.map +10 -0
- package/dist/jobs/assets/main-0jmeh6mq.js +6 -0
- package/dist/jobs/assets/main-0jmeh6mq.js.map +12 -0
- package/dist/jobs/assets/main-3yj6bv90.js +5 -0
- package/dist/jobs/assets/main-3yj6bv90.js.map +10 -0
- package/dist/jobs/assets/main-4g69fn3x.js +5 -0
- package/dist/jobs/assets/main-4g69fn3x.js.map +10 -0
- package/dist/jobs/assets/main-4gte3eqq.js +5 -0
- package/dist/jobs/assets/main-4gte3eqq.js.map +10 -0
- package/dist/jobs/assets/main-4j1fcevh.js +5 -0
- package/dist/jobs/assets/main-4j1fcevh.js.map +10 -0
- package/dist/jobs/assets/main-54qwkz28.css +1 -0
- package/dist/jobs/assets/main-5grjvsy7.js +5 -0
- package/dist/jobs/assets/main-5grjvsy7.js.map +11 -0
- package/dist/jobs/assets/main-5zbx60gq.js +5 -0
- package/dist/jobs/assets/main-5zbx60gq.js.map +11 -0
- package/dist/jobs/assets/main-9prgjhg4.js +5 -0
- package/dist/jobs/assets/main-9prgjhg4.js.map +10 -0
- package/dist/jobs/assets/main-a250ffk7.js +17 -0
- package/dist/jobs/assets/main-a250ffk7.js.map +115 -0
- package/dist/jobs/assets/main-d6ge7w08.js +5 -0
- package/dist/jobs/assets/main-d6ge7w08.js.map +10 -0
- package/dist/jobs/assets/main-gngzht43.js +5 -0
- package/dist/jobs/assets/main-gngzht43.js.map +10 -0
- package/dist/jobs/assets/main-hg0hrg8j.js +5 -0
- package/dist/jobs/assets/main-hg0hrg8j.js.map +10 -0
- package/dist/jobs/assets/main-hm1ng3yg.js +5 -0
- package/dist/jobs/assets/main-hm1ng3yg.js.map +12 -0
- package/dist/jobs/assets/main-jkvd92z9.js +5 -0
- package/dist/jobs/assets/main-jkvd92z9.js.map +10 -0
- package/dist/jobs/assets/main-jv5xqd13.js +5 -0
- package/dist/jobs/assets/main-jv5xqd13.js.map +21 -0
- package/dist/jobs/assets/main-jxre7w93.js +5 -0
- package/dist/jobs/assets/main-jxre7w93.js.map +23 -0
- package/dist/jobs/assets/main-kwmqkjfx.js +5 -0
- package/dist/jobs/assets/main-kwmqkjfx.js.map +11 -0
- package/dist/jobs/assets/main-n9cf6bxm.js +5 -0
- package/dist/jobs/assets/main-n9cf6bxm.js.map +11 -0
- package/dist/jobs/assets/main-pegp0ygb.js +5 -0
- package/dist/jobs/assets/main-pegp0ygb.js.map +10 -0
- package/dist/jobs/assets/main-qr573853.js +5 -0
- package/dist/jobs/assets/main-qr573853.js.map +12 -0
- package/dist/jobs/assets/main-rq97rdr4.js +4 -0
- package/dist/jobs/assets/main-rq97rdr4.js.map +22 -0
- package/dist/jobs/assets/main-szjdmydc.js +5 -0
- package/dist/jobs/assets/main-szjdmydc.js.map +13 -0
- package/dist/jobs/assets/main-tnbhrm9n.js +7 -0
- package/dist/jobs/assets/main-tnbhrm9n.js.map +11 -0
- package/dist/jobs/assets/main-ts19j4n0.js +5 -0
- package/dist/jobs/assets/main-ts19j4n0.js.map +25 -0
- package/dist/jobs/assets/main-w93x1dzs.js +5 -0
- package/dist/jobs/assets/main-w93x1dzs.js.map +10 -0
- package/dist/jobs/assets/sections-0ksdxs8m.css +1 -0
- package/dist/jobs/assets/sections-0y7w4fb4.js +4 -0
- package/dist/jobs/assets/sections-0y7w4fb4.js.map +10 -0
- package/dist/jobs/assets/sse-mn0t0fh7.js +5 -0
- package/dist/jobs/assets/sse-mn0t0fh7.js.map +11 -0
- package/dist/jobs/assets/sseChecks-t7tcm7ra.js +4 -0
- package/dist/jobs/assets/sseChecks-t7tcm7ra.js.map +10 -0
- package/dist/jobs/assets/validation-ysjfy072.js +4 -0
- package/dist/jobs/assets/validation-ysjfy072.js.map +9 -0
- package/dist/jobs/manifest.json +511 -0
- package/dts/docs/assets.d.ts +144 -0
- package/dts/docs/assets.d.ts.map +1 -0
- package/dts/docs/browser/config.d.ts +61 -0
- package/dts/docs/browser/config.d.ts.map +1 -0
- package/dts/docs/browser/index.d.ts +12 -0
- package/dts/docs/browser/index.d.ts.map +1 -0
- package/dts/docs/docsUi.d.ts +42 -0
- package/dts/docs/docsUi.d.ts.map +1 -0
- package/dts/docs/hosted.d.ts +22 -0
- package/dts/docs/hosted.d.ts.map +1 -0
- package/dts/docs/index.d.ts +18 -0
- package/dts/docs/index.d.ts.map +1 -0
- package/dts/docs/shell.d.ts +57 -0
- package/dts/docs/shell.d.ts.map +1 -0
- package/dts/docs/types.d.ts +137 -0
- package/dts/docs/types.d.ts.map +1 -0
- package/dts/index.d.ts +21 -0
- package/dts/index.d.ts.map +1 -0
- package/dts/jobs/assets.d.ts +157 -0
- package/dts/jobs/assets.d.ts.map +1 -0
- package/dts/jobs/browser/config.d.ts +67 -0
- package/dts/jobs/browser/config.d.ts.map +1 -0
- package/dts/jobs/browser/index.d.ts +10 -0
- package/dts/jobs/browser/index.d.ts.map +1 -0
- package/dts/jobs/browser/unauthorized.d.ts +9 -0
- package/dts/jobs/browser/unauthorized.d.ts.map +1 -0
- package/dts/jobs/index.d.ts +24 -0
- package/dts/jobs/index.d.ts.map +1 -0
- package/dts/jobs/jobsUi.d.ts +63 -0
- package/dts/jobs/jobsUi.d.ts.map +1 -0
- package/dts/jobs/shell.d.ts +77 -0
- package/dts/jobs/shell.d.ts.map +1 -0
- package/dts/jobs/types.d.ts +146 -0
- package/dts/jobs/types.d.ts.map +1 -0
- package/dts/kit/browser/shared.d.ts +10 -0
- package/dts/kit/browser/shared.d.ts.map +1 -0
- package/dts/kit/browser/unauthorized.d.ts +74 -0
- package/dts/kit/browser/unauthorized.d.ts.map +1 -0
- package/dts/kit/unauthorized.d.ts +47 -0
- package/dts/kit/unauthorized.d.ts.map +1 -0
- package/guides/docs.md +170 -0
- package/guides/jobs.md +1120 -0
- package/lib/docs/assets.ts +427 -0
- package/lib/docs/browser/config.ts +66 -0
- package/lib/docs/browser/index.ts +24 -0
- package/lib/docs/docsUi.ts +623 -0
- package/lib/docs/hosted.ts +31 -0
- package/lib/docs/index.ts +37 -0
- package/lib/docs/shell.ts +156 -0
- package/lib/docs/types.ts +149 -0
- package/lib/index.ts +27 -0
- package/lib/jobs/assets.ts +448 -0
- package/lib/jobs/browser/config.ts +65 -0
- package/lib/jobs/browser/index.ts +9 -0
- package/lib/jobs/browser/unauthorized.ts +11 -0
- package/lib/jobs/index.ts +41 -0
- package/lib/jobs/jobsUi.ts +709 -0
- package/lib/jobs/shell.ts +205 -0
- package/lib/jobs/types.ts +162 -0
- package/lib/kit/browser/shared.ts +11 -0
- package/lib/kit/browser/unauthorized.ts +114 -0
- package/lib/kit/unauthorized.ts +222 -0
- package/package.json +198 -4
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clipboard and JSON-serialisation helpers shared by {@link CopyButton} and
|
|
3
|
+
* {@link JsonView}.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** How long a {@link CopyButton}'s "Copied" / "Copy failed" feedback shows, in ms. */
|
|
7
|
+
export const COPY_FEEDBACK_MS = 1_500;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Writes `text` to the clipboard. Resolves `false` instead of throwing when
|
|
11
|
+
* the Clipboard API is missing (an insecure origin, an old browser) or the
|
|
12
|
+
* write is refused (no permission, the document not focused).
|
|
13
|
+
*/
|
|
14
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
15
|
+
try {
|
|
16
|
+
if (typeof navigator === "undefined" || !navigator.clipboard) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
await navigator.clipboard.writeText(text);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What {@link toJsonSafe} puts where a reference points back at an ancestor. */
|
|
27
|
+
export const CIRCULAR_MARKER = "[Circular]";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A JSON-safe copy of any value: circular references become
|
|
31
|
+
* {@link CIRCULAR_MARKER} (a shared, non-circular reference is kept), a
|
|
32
|
+
* `bigint` becomes its decimal string, `undefined` inside an array becomes
|
|
33
|
+
* `null` (as `JSON.stringify` would), functions and symbols are dropped from
|
|
34
|
+
* objects, and anything with `toJSON` (a `Date`) is serialised through it.
|
|
35
|
+
*
|
|
36
|
+
* The guard is a `WeakSet` of the objects on the current path: added on the
|
|
37
|
+
* way down, deleted on the way back up, so it flags cycles and nothing else.
|
|
38
|
+
*/
|
|
39
|
+
export function toJsonSafe(value: unknown): unknown {
|
|
40
|
+
const path = new WeakSet<object>();
|
|
41
|
+
const walk = (current: unknown): unknown => {
|
|
42
|
+
if (typeof current === "bigint") {
|
|
43
|
+
return current.toString();
|
|
44
|
+
}
|
|
45
|
+
if (typeof current === "function" || typeof current === "symbol") {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (typeof current !== "object" || current === null) {
|
|
49
|
+
return current;
|
|
50
|
+
}
|
|
51
|
+
if (path.has(current)) {
|
|
52
|
+
return CIRCULAR_MARKER;
|
|
53
|
+
}
|
|
54
|
+
const withJson = current as { toJSON?: unknown };
|
|
55
|
+
if (typeof withJson.toJSON === "function") {
|
|
56
|
+
return walk((withJson.toJSON as () => unknown).call(current));
|
|
57
|
+
}
|
|
58
|
+
path.add(current);
|
|
59
|
+
try {
|
|
60
|
+
if (Array.isArray(current)) {
|
|
61
|
+
return current.map((item) => {
|
|
62
|
+
const safe = walk(item);
|
|
63
|
+
return safe === undefined ? null : safe;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const out: Record<string, unknown> = {};
|
|
67
|
+
for (const [key, item] of Object.entries(current)) {
|
|
68
|
+
const safe = walk(item);
|
|
69
|
+
if (safe !== undefined) {
|
|
70
|
+
out[key] = safe;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
} finally {
|
|
75
|
+
path.delete(current);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return walk(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `JSON.stringify` that never throws: circular input and bigints are handled
|
|
83
|
+
* by {@link toJsonSafe}, and a bare `undefined` becomes the text `undefined`.
|
|
84
|
+
*/
|
|
85
|
+
export function safeStringify(value: unknown, indent = 2): string {
|
|
86
|
+
const text = JSON.stringify(toJsonSafe(value), null, indent) as
|
|
87
|
+
string | undefined;
|
|
88
|
+
return text ?? "undefined";
|
|
89
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createContext, use } from "react";
|
|
2
|
+
|
|
3
|
+
/** What a {@link Field} tells the control inside it. */
|
|
4
|
+
export interface FieldControlProps {
|
|
5
|
+
/** The control's `id`; the field's `<label htmlFor>` points at it. */
|
|
6
|
+
id: string;
|
|
7
|
+
/** Ids of the hint and error (and any extra description), space separated; `undefined` when there are none. */
|
|
8
|
+
"aria-describedby": string | undefined;
|
|
9
|
+
/** `true` while the field shows an error. */
|
|
10
|
+
"aria-invalid": boolean | undefined;
|
|
11
|
+
/** Whether the field is marked required. */
|
|
12
|
+
required: boolean | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Carries the enclosing field's wiring; `null` outside a field. */
|
|
16
|
+
export const FieldContext = createContext<FieldControlProps | null>(null);
|
|
17
|
+
|
|
18
|
+
/** The enclosing {@link Field}'s wiring, or `null` when the control stands alone. */
|
|
19
|
+
export function useField(): FieldControlProps | null {
|
|
20
|
+
return use(FieldContext);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Joins ids for `aria-describedby`, skipping empty ones; `undefined` when none are left. */
|
|
24
|
+
export function joinIds(
|
|
25
|
+
...ids: (string | false | null | undefined)[]
|
|
26
|
+
): string | undefined {
|
|
27
|
+
const joined = ids.filter(Boolean).join(" ");
|
|
28
|
+
return joined === "" ? undefined : joined;
|
|
29
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { MAX_DATE_MS } from "../api/contract";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Value conversions of the kit's inputs, pure. The `datetime-local` value
|
|
5
|
+
* (`YYYY-MM-DDTHH:mm[:ss]`) is wall-clock time in the browser's own time
|
|
6
|
+
* zone.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Parses an `<input type="number">` value: `""` (empty or unparsable) → `undefined`. */
|
|
10
|
+
export function parseNumberInput(text: string): number | undefined {
|
|
11
|
+
if (text.trim() === "") {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const value = Number(text);
|
|
15
|
+
return Number.isFinite(value) ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Pads to two digits. */
|
|
19
|
+
function pad(value: number): string {
|
|
20
|
+
return String(value).padStart(2, "0");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Epoch ms → a `datetime-local` value in local time. Seconds are included
|
|
25
|
+
* when `withSeconds` is set; otherwise the value is truncated to the minute.
|
|
26
|
+
* `undefined`/`NaN` → `""`.
|
|
27
|
+
*/
|
|
28
|
+
export function toDateTimeLocal(
|
|
29
|
+
ms: number | undefined | null,
|
|
30
|
+
withSeconds = false,
|
|
31
|
+
): string {
|
|
32
|
+
if (ms === undefined || ms === null || !Number.isFinite(ms)) {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
const date = new Date(ms);
|
|
36
|
+
const day = `${String(date.getFullYear()).padStart(4, "0")}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
37
|
+
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
38
|
+
return `${day}T${time}${withSeconds ? `:${pad(date.getSeconds())}` : ""}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The shape a `datetime-local` value takes. */
|
|
42
|
+
const DATE_TIME_LOCAL =
|
|
43
|
+
/^(\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/;
|
|
44
|
+
|
|
45
|
+
/** A `datetime-local` value → epoch ms, read as local time; `undefined` when empty or malformed. */
|
|
46
|
+
export function fromDateTimeLocal(text: string): number | undefined {
|
|
47
|
+
const match = DATE_TIME_LOCAL.exec(text.trim());
|
|
48
|
+
if (!match) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const [, year, month, day, hours, minutes, seconds, fraction] = match;
|
|
52
|
+
const ms = new Date(
|
|
53
|
+
Number(year),
|
|
54
|
+
Number(month) - 1,
|
|
55
|
+
Number(day),
|
|
56
|
+
Number(hours),
|
|
57
|
+
Number(minutes),
|
|
58
|
+
Number(seconds ?? 0),
|
|
59
|
+
Number((fraction ?? "0").padEnd(3, "0")),
|
|
60
|
+
).getTime();
|
|
61
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Shown for a `datetime-local` value the browser could not read, or that is malformed. */
|
|
65
|
+
export const DATE_TIME_UNREADABLE =
|
|
66
|
+
"Not a date and time this browser can read. Pick one again, or clear the field.";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Shown for a time outside what the API accepts: epoch ms from 0 to
|
|
70
|
+
* `MAX_DATE_MS`, the last instant a `Date` holds (the API refuses anything
|
|
71
|
+
* else with 400 `VALIDATION` at the field).
|
|
72
|
+
*/
|
|
73
|
+
export const DATE_TIME_OUT_OF_RANGE =
|
|
74
|
+
"Pick a time from 1 January 1970 to 13 September 275760 (UTC): the API accepts no other.";
|
|
75
|
+
|
|
76
|
+
/** What {@link readDateTimeLocal} found in a `datetime-local` value. */
|
|
77
|
+
export type DateTimeReading =
|
|
78
|
+
/** Nothing: the field was cleared. */
|
|
79
|
+
| { kind: "empty" }
|
|
80
|
+
/** A time the API accepts, epoch ms. */
|
|
81
|
+
| { kind: "time"; ms: number }
|
|
82
|
+
/** Something unusable; `problem` says why, for the field's error. */
|
|
83
|
+
| { kind: "problem"; problem: string };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reads a `datetime-local` value the way the API will judge it. Empty is
|
|
87
|
+
* `empty` unless the browser flags a half-typed entry (`badInput`, for which
|
|
88
|
+
* it reports `""`). A malformed value, or one naming a day that does not
|
|
89
|
+
* exist, is {@link DATE_TIME_UNREADABLE}; a time before the epoch or after
|
|
90
|
+
* `MAX_DATE_MS` (which `Date` cannot even build) is
|
|
91
|
+
* {@link DATE_TIME_OUT_OF_RANGE}.
|
|
92
|
+
*/
|
|
93
|
+
export function readDateTimeLocal(
|
|
94
|
+
text: string,
|
|
95
|
+
badInput = false,
|
|
96
|
+
): DateTimeReading {
|
|
97
|
+
const trimmed = text.trim();
|
|
98
|
+
if (trimmed === "") {
|
|
99
|
+
return badInput
|
|
100
|
+
? { kind: "problem", problem: DATE_TIME_UNREADABLE }
|
|
101
|
+
: { kind: "empty" };
|
|
102
|
+
}
|
|
103
|
+
const match = DATE_TIME_LOCAL.exec(trimmed);
|
|
104
|
+
if (!match) {
|
|
105
|
+
return { kind: "problem", problem: DATE_TIME_UNREADABLE };
|
|
106
|
+
}
|
|
107
|
+
const [, year, month, day, hours, minutes, seconds] = match.map(Number);
|
|
108
|
+
if (
|
|
109
|
+
month < 1 ||
|
|
110
|
+
month > 12 ||
|
|
111
|
+
day < 1 ||
|
|
112
|
+
day > daysInMonth(year, month) ||
|
|
113
|
+
hours > 23 ||
|
|
114
|
+
minutes > 59 ||
|
|
115
|
+
(seconds ?? 0) > 59
|
|
116
|
+
) {
|
|
117
|
+
return { kind: "problem", problem: DATE_TIME_UNREADABLE };
|
|
118
|
+
}
|
|
119
|
+
const ms = fromDateTimeLocal(trimmed);
|
|
120
|
+
if (ms === undefined || ms < 0 || ms > MAX_DATE_MS) {
|
|
121
|
+
return { kind: "problem", problem: DATE_TIME_OUT_OF_RANGE };
|
|
122
|
+
}
|
|
123
|
+
return { kind: "time", ms };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Days in a month (1-12) of a proleptic Gregorian year. */
|
|
127
|
+
function daysInMonth(year: number, month: number): number {
|
|
128
|
+
if (month === 2) {
|
|
129
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
130
|
+
return leap ? 29 : 28;
|
|
131
|
+
}
|
|
132
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The browser's time zone name (e.g. `"Europe/London"`), or `"local time"` when unknown. */
|
|
136
|
+
export function localTimeZone(): string {
|
|
137
|
+
try {
|
|
138
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "local time";
|
|
139
|
+
} catch {
|
|
140
|
+
return "local time";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangeEvent,
|
|
3
|
+
InputHTMLAttributes,
|
|
4
|
+
ReactNode,
|
|
5
|
+
Ref,
|
|
6
|
+
SelectHTMLAttributes,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
9
|
+
import { cx } from "./classNames";
|
|
10
|
+
import { joinIds, useField } from "./fieldContext";
|
|
11
|
+
import {
|
|
12
|
+
localTimeZone,
|
|
13
|
+
parseNumberInput,
|
|
14
|
+
readDateTimeLocal,
|
|
15
|
+
toDateTimeLocal,
|
|
16
|
+
} from "./inputValues";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The kit's form controls. Inside a {@link Field} each takes its id,
|
|
20
|
+
* `aria-describedby`, `aria-invalid` and `required` from it; explicit props
|
|
21
|
+
* win, and an explicit `aria-describedby` is added to the field's.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Wiring props every control accepts. */
|
|
25
|
+
interface ControlWiring {
|
|
26
|
+
/** The element id. Defaults to the enclosing field's. */
|
|
27
|
+
id?: string;
|
|
28
|
+
/** Extra description ids, added to the field's. */
|
|
29
|
+
"aria-describedby"?: string;
|
|
30
|
+
/** Invalid state. Defaults to the enclosing field's. */
|
|
31
|
+
"aria-invalid"?: boolean;
|
|
32
|
+
/** Required. Defaults to the enclosing field's. */
|
|
33
|
+
required?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolves a control's wiring against the enclosing field. */
|
|
37
|
+
function useWiring(props: ControlWiring) {
|
|
38
|
+
const field = useField();
|
|
39
|
+
return {
|
|
40
|
+
id: props.id ?? field?.id,
|
|
41
|
+
"aria-describedby": joinIds(
|
|
42
|
+
field?.["aria-describedby"],
|
|
43
|
+
props["aria-describedby"],
|
|
44
|
+
),
|
|
45
|
+
"aria-invalid": props["aria-invalid"] ?? field?.["aria-invalid"],
|
|
46
|
+
required: props.required ?? field?.required,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Native input props the kit's inputs pass through (value and change are typed per control). */
|
|
51
|
+
type PassThroughInputProps = Omit<
|
|
52
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
53
|
+
| "value"
|
|
54
|
+
| "defaultValue"
|
|
55
|
+
| "onChange"
|
|
56
|
+
| "type"
|
|
57
|
+
| "id"
|
|
58
|
+
| "aria-describedby"
|
|
59
|
+
| "aria-invalid"
|
|
60
|
+
| "required"
|
|
61
|
+
| "min"
|
|
62
|
+
| "max"
|
|
63
|
+
| "step"
|
|
64
|
+
> & {
|
|
65
|
+
/** The underlying `<input>`, e.g. to focus it. */
|
|
66
|
+
ref?: Ref<HTMLInputElement>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Props of {@link TextInput}. */
|
|
70
|
+
export interface TextInputProps extends PassThroughInputProps, ControlWiring {
|
|
71
|
+
/** The text. */
|
|
72
|
+
value: string;
|
|
73
|
+
/** Called with the new text on each edit. */
|
|
74
|
+
onChange: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
|
|
75
|
+
/** The input type. Defaults to `"text"`. */
|
|
76
|
+
type?: "text" | "search" | "email" | "url" | "password" | "tel";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A single-line text input. */
|
|
80
|
+
export function TextInput({
|
|
81
|
+
value,
|
|
82
|
+
onChange,
|
|
83
|
+
type = "text",
|
|
84
|
+
className,
|
|
85
|
+
...rest
|
|
86
|
+
}: TextInputProps) {
|
|
87
|
+
const wiring = useWiring(rest);
|
|
88
|
+
return (
|
|
89
|
+
<input
|
|
90
|
+
{...rest}
|
|
91
|
+
{...wiring}
|
|
92
|
+
type={type}
|
|
93
|
+
className={cx("input", className)}
|
|
94
|
+
value={value}
|
|
95
|
+
onChange={(event) => onChange(event.target.value, event)}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Props of {@link NumberInput}. */
|
|
101
|
+
export interface NumberInputProps extends PassThroughInputProps, ControlWiring {
|
|
102
|
+
/** The number, or `undefined` for empty. */
|
|
103
|
+
value: number | undefined;
|
|
104
|
+
/** Called with the new number, or `undefined` when the input is empty (or not a number). */
|
|
105
|
+
onChange: (
|
|
106
|
+
value: number | undefined,
|
|
107
|
+
event: ChangeEvent<HTMLInputElement>,
|
|
108
|
+
) => void;
|
|
109
|
+
/** Smallest allowed value (native validation only; the value is not clamped). */
|
|
110
|
+
min?: number;
|
|
111
|
+
/** Largest allowed value (native validation only; the value is not clamped). */
|
|
112
|
+
max?: number;
|
|
113
|
+
/** Step. Defaults to `1`; pass `"any"` for decimals. */
|
|
114
|
+
step?: number | "any";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A number input where empty means `undefined`, never `0`. */
|
|
118
|
+
export function NumberInput({
|
|
119
|
+
value,
|
|
120
|
+
onChange,
|
|
121
|
+
min,
|
|
122
|
+
max,
|
|
123
|
+
step = 1,
|
|
124
|
+
className,
|
|
125
|
+
...rest
|
|
126
|
+
}: NumberInputProps) {
|
|
127
|
+
const wiring = useWiring(rest);
|
|
128
|
+
return (
|
|
129
|
+
<input
|
|
130
|
+
inputMode={step === "any" ? "decimal" : "numeric"}
|
|
131
|
+
{...rest}
|
|
132
|
+
{...wiring}
|
|
133
|
+
type="number"
|
|
134
|
+
className={cx("input", "input-number", className)}
|
|
135
|
+
min={min}
|
|
136
|
+
max={max}
|
|
137
|
+
step={step}
|
|
138
|
+
value={value === undefined || Number.isNaN(value) ? "" : value}
|
|
139
|
+
onChange={(event) =>
|
|
140
|
+
onChange(parseNumberInput(event.target.value), event)
|
|
141
|
+
}
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One option of a {@link Select}. */
|
|
147
|
+
export interface SelectOption<V extends string> {
|
|
148
|
+
/** The value. */
|
|
149
|
+
value: V;
|
|
150
|
+
/** The text shown. Defaults to the value. */
|
|
151
|
+
label?: string;
|
|
152
|
+
/** Not selectable. */
|
|
153
|
+
disabled?: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Props of {@link Select}. */
|
|
157
|
+
export interface SelectProps<V extends string>
|
|
158
|
+
extends
|
|
159
|
+
Omit<
|
|
160
|
+
SelectHTMLAttributes<HTMLSelectElement>,
|
|
161
|
+
| "value"
|
|
162
|
+
| "defaultValue"
|
|
163
|
+
| "onChange"
|
|
164
|
+
| "id"
|
|
165
|
+
| "aria-describedby"
|
|
166
|
+
| "aria-invalid"
|
|
167
|
+
| "required"
|
|
168
|
+
| "multiple"
|
|
169
|
+
>,
|
|
170
|
+
ControlWiring {
|
|
171
|
+
/** The choices. Include `{ value: "", label: "Any" }` (with `""` in `V`) for an "unset" choice. */
|
|
172
|
+
options: readonly SelectOption<V>[];
|
|
173
|
+
/** The selected value. */
|
|
174
|
+
value: V;
|
|
175
|
+
/** Called with the chosen value. */
|
|
176
|
+
onChange: (value: V, event: ChangeEvent<HTMLSelectElement>) => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A native `<select>` over a typed set of string values. */
|
|
180
|
+
export function Select<V extends string>({
|
|
181
|
+
options,
|
|
182
|
+
value,
|
|
183
|
+
onChange,
|
|
184
|
+
className,
|
|
185
|
+
...rest
|
|
186
|
+
}: SelectProps<V>) {
|
|
187
|
+
const wiring = useWiring(rest);
|
|
188
|
+
return (
|
|
189
|
+
<select
|
|
190
|
+
{...rest}
|
|
191
|
+
{...wiring}
|
|
192
|
+
className={cx("input", "select", className)}
|
|
193
|
+
value={value}
|
|
194
|
+
onChange={(event) => {
|
|
195
|
+
const chosen = options.find(
|
|
196
|
+
(option) => option.value === event.target.value,
|
|
197
|
+
);
|
|
198
|
+
if (chosen) {
|
|
199
|
+
onChange(chosen.value, event);
|
|
200
|
+
}
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
{options.map((option) => (
|
|
204
|
+
<option
|
|
205
|
+
key={option.value}
|
|
206
|
+
value={option.value}
|
|
207
|
+
disabled={option.disabled}
|
|
208
|
+
>
|
|
209
|
+
{option.label ?? option.value}
|
|
210
|
+
</option>
|
|
211
|
+
))}
|
|
212
|
+
</select>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Props of {@link Checkbox}. */
|
|
217
|
+
export interface CheckboxProps extends Omit<
|
|
218
|
+
PassThroughInputProps,
|
|
219
|
+
"checked" | "defaultChecked"
|
|
220
|
+
> {
|
|
221
|
+
/** Whether it is ticked. */
|
|
222
|
+
checked: boolean;
|
|
223
|
+
/** Called with the new state. */
|
|
224
|
+
onChange: (checked: boolean, event: ChangeEvent<HTMLInputElement>) => void;
|
|
225
|
+
/** The label beside the box; clicking it toggles. */
|
|
226
|
+
label: ReactNode;
|
|
227
|
+
/** Help under the label; it describes the box. */
|
|
228
|
+
hint?: ReactNode;
|
|
229
|
+
/** The element id. Defaults to a generated one. */
|
|
230
|
+
id?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A checkbox with its own label (it does not need a {@link Field}). */
|
|
234
|
+
export function Checkbox({
|
|
235
|
+
checked,
|
|
236
|
+
onChange,
|
|
237
|
+
label,
|
|
238
|
+
hint,
|
|
239
|
+
id,
|
|
240
|
+
className,
|
|
241
|
+
...rest
|
|
242
|
+
}: CheckboxProps) {
|
|
243
|
+
const generatedId = useId();
|
|
244
|
+
const inputId = id ?? generatedId;
|
|
245
|
+
const hintId = `${inputId}-hint`;
|
|
246
|
+
return (
|
|
247
|
+
<div className={cx("checkbox", className)}>
|
|
248
|
+
<input
|
|
249
|
+
{...rest}
|
|
250
|
+
id={inputId}
|
|
251
|
+
type="checkbox"
|
|
252
|
+
checked={checked}
|
|
253
|
+
aria-describedby={hint ? hintId : undefined}
|
|
254
|
+
onChange={(event) => onChange(event.target.checked, event)}
|
|
255
|
+
/>
|
|
256
|
+
<label htmlFor={inputId}>{label}</label>
|
|
257
|
+
{hint && (
|
|
258
|
+
<p
|
|
259
|
+
id={hintId}
|
|
260
|
+
className="field-hint checkbox-hint"
|
|
261
|
+
>
|
|
262
|
+
{hint}
|
|
263
|
+
</p>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Props of {@link DateTimeInput}. */
|
|
270
|
+
export interface DateTimeInputProps
|
|
271
|
+
extends Omit<PassThroughInputProps, "min" | "max">, ControlWiring {
|
|
272
|
+
/** The instant, epoch ms, or `undefined` for empty. */
|
|
273
|
+
value: number | undefined;
|
|
274
|
+
/**
|
|
275
|
+
* Called with the instant (epoch ms) the user picked, or `undefined` when
|
|
276
|
+
* cleared, or when the field holds something unusable (then `onProblem`
|
|
277
|
+
* says what).
|
|
278
|
+
*/
|
|
279
|
+
onChange: (
|
|
280
|
+
value: number | undefined,
|
|
281
|
+
event: ChangeEvent<HTMLInputElement>,
|
|
282
|
+
) => void;
|
|
283
|
+
/**
|
|
284
|
+
* Called with a message when the field holds something unusable — a time
|
|
285
|
+
* outside what the API accepts (0 to `MAX_DATE_MS`) or one the browser
|
|
286
|
+
* could not read — and with `undefined` once it holds a usable time, is
|
|
287
|
+
* cleared, or unmounts. Judged on each change and again on blur, since a
|
|
288
|
+
* half-typed entry into an empty field fires no change. The typed text is
|
|
289
|
+
* kept on screen meanwhile. Show the message as the field's error and hold
|
|
290
|
+
* the form back until it clears (`useFieldProblems`, in
|
|
291
|
+
* `hooks/useFieldProblems`).
|
|
292
|
+
*/
|
|
293
|
+
onProblem?: (problem: string | undefined) => void;
|
|
294
|
+
/** Earliest allowed instant, epoch ms (native validation only). */
|
|
295
|
+
min?: number;
|
|
296
|
+
/** Latest allowed instant, epoch ms (native validation only). */
|
|
297
|
+
max?: number;
|
|
298
|
+
/** Let the user pick seconds. Defaults to `false` (minute precision). */
|
|
299
|
+
withSeconds?: boolean;
|
|
300
|
+
/** Show the browser's time zone beside the input. Defaults to `true`. */
|
|
301
|
+
showTimeZone?: boolean;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A `datetime-local` input speaking epoch ms. The wall-clock value is read
|
|
306
|
+
* in the browser's time zone, which is shown beside it (and part of its
|
|
307
|
+
* description), so "09:00" is never ambiguous.
|
|
308
|
+
*/
|
|
309
|
+
export function DateTimeInput({
|
|
310
|
+
value,
|
|
311
|
+
onChange,
|
|
312
|
+
onProblem,
|
|
313
|
+
min,
|
|
314
|
+
max,
|
|
315
|
+
withSeconds = false,
|
|
316
|
+
showTimeZone = true,
|
|
317
|
+
className,
|
|
318
|
+
onBlur,
|
|
319
|
+
...rest
|
|
320
|
+
}: DateTimeInputProps) {
|
|
321
|
+
const zoneId = useId();
|
|
322
|
+
const wiring = useWiring(rest);
|
|
323
|
+
// What the user typed, kept on screen while it is unusable (the value is
|
|
324
|
+
// then `undefined`, which would otherwise blank the field).
|
|
325
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
326
|
+
const onProblemRef = useRef(onProblem);
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
onProblemRef.current = onProblem;
|
|
329
|
+
});
|
|
330
|
+
// A field that goes away takes its problem with it.
|
|
331
|
+
useEffect(() => () => onProblemRef.current?.(undefined), []);
|
|
332
|
+
return (
|
|
333
|
+
<span className="input-group">
|
|
334
|
+
<input
|
|
335
|
+
{...rest}
|
|
336
|
+
{...wiring}
|
|
337
|
+
aria-describedby={joinIds(
|
|
338
|
+
wiring["aria-describedby"],
|
|
339
|
+
showTimeZone && zoneId,
|
|
340
|
+
)}
|
|
341
|
+
type="datetime-local"
|
|
342
|
+
className={cx("input", "input-datetime", className)}
|
|
343
|
+
step={withSeconds ? 1 : 60}
|
|
344
|
+
min={min === undefined ? undefined : toDateTimeLocal(min, withSeconds)}
|
|
345
|
+
max={max === undefined ? undefined : toDateTimeLocal(max, withSeconds)}
|
|
346
|
+
value={
|
|
347
|
+
value === undefined
|
|
348
|
+
? (draft ?? "")
|
|
349
|
+
: toDateTimeLocal(value, withSeconds)
|
|
350
|
+
}
|
|
351
|
+
onChange={(event) => {
|
|
352
|
+
const reading = readDateTimeLocal(
|
|
353
|
+
event.target.value,
|
|
354
|
+
event.target.validity.badInput,
|
|
355
|
+
);
|
|
356
|
+
if (reading.kind === "problem") {
|
|
357
|
+
setDraft(event.target.value);
|
|
358
|
+
onChange(undefined, event);
|
|
359
|
+
onProblem?.(reading.problem);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
setDraft(null);
|
|
363
|
+
onChange(reading.kind === "time" ? reading.ms : undefined, event);
|
|
364
|
+
onProblem?.(undefined);
|
|
365
|
+
}}
|
|
366
|
+
// A half-typed entry into an empty field fires no change (its value
|
|
367
|
+
// stays ""), so it is judged again on leaving the field.
|
|
368
|
+
onBlur={(event) => {
|
|
369
|
+
onBlur?.(event);
|
|
370
|
+
const reading = readDateTimeLocal(
|
|
371
|
+
event.target.value,
|
|
372
|
+
event.target.validity.badInput,
|
|
373
|
+
);
|
|
374
|
+
onProblem?.(reading.kind === "problem" ? reading.problem : undefined);
|
|
375
|
+
}}
|
|
376
|
+
/>
|
|
377
|
+
{showTimeZone && (
|
|
378
|
+
<span
|
|
379
|
+
id={zoneId}
|
|
380
|
+
className="input-suffix"
|
|
381
|
+
>
|
|
382
|
+
{localTimeZone()}
|
|
383
|
+
</span>
|
|
384
|
+
)}
|
|
385
|
+
</span>
|
|
386
|
+
);
|
|
387
|
+
}
|