@dowel-ui/react 0.3.0 → 0.5.0
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/README.md +13 -8
- package/dist/components/ai-disclosure/ai-disclosure.d.ts +92 -0
- package/dist/components/ai-disclosure/ai-disclosure.d.ts.map +1 -0
- package/dist/components/ai-disclosure/ai-disclosure.js +122 -0
- package/dist/components/ai-disclosure/ai-disclosure.js.map +1 -0
- package/dist/components/ai-disclosure/index.d.ts +2 -0
- package/dist/components/ai-disclosure/index.js +2 -0
- package/dist/components/ai-disclosure/meta.js +17 -0
- package/dist/components/ai-disclosure/meta.js.map +1 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.d.ts +52 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.d.ts.map +1 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.js +249 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.js.map +1 -0
- package/dist/components/ai-extraction-review/extraction-model.d.ts +94 -0
- package/dist/components/ai-extraction-review/extraction-model.d.ts.map +1 -0
- package/dist/components/ai-extraction-review/extraction-model.js +91 -0
- package/dist/components/ai-extraction-review/extraction-model.js.map +1 -0
- package/dist/components/ai-extraction-review/index.d.ts +3 -0
- package/dist/components/ai-extraction-review/index.js +3 -0
- package/dist/components/ai-extraction-review/meta.js +17 -0
- package/dist/components/ai-extraction-review/meta.js.map +1 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.d.ts +72 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.d.ts.map +1 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.js +126 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.js.map +1 -0
- package/dist/components/ai-suggested-value/index.d.ts +2 -0
- package/dist/components/ai-suggested-value/index.js +2 -0
- package/dist/components/ai-suggested-value/meta.js +17 -0
- package/dist/components/ai-suggested-value/meta.js.map +1 -0
- package/dist/components/alert/alert.d.ts +1 -1
- package/dist/components/avatar/avatar.d.ts +1 -1
- package/dist/components/badge/badge.d.ts +1 -1
- package/dist/components/button/button.d.ts +1 -1
- package/dist/components/confirm-typed/confirm-typed.d.ts +47 -0
- package/dist/components/confirm-typed/confirm-typed.d.ts.map +1 -0
- package/dist/components/confirm-typed/confirm-typed.js +106 -0
- package/dist/components/confirm-typed/confirm-typed.js.map +1 -0
- package/dist/components/confirm-typed/index.d.ts +2 -0
- package/dist/components/confirm-typed/index.js +2 -0
- package/dist/components/confirm-typed/meta.js +17 -0
- package/dist/components/confirm-typed/meta.js.map +1 -0
- package/dist/components/cron-editor/cron-editor.d.ts +36 -0
- package/dist/components/cron-editor/cron-editor.d.ts.map +1 -0
- package/dist/components/cron-editor/cron-editor.js +493 -0
- package/dist/components/cron-editor/cron-editor.js.map +1 -0
- package/dist/components/cron-editor/cron-expression.d.ts +82 -0
- package/dist/components/cron-editor/cron-expression.d.ts.map +1 -0
- package/dist/components/cron-editor/cron-expression.js +352 -0
- package/dist/components/cron-editor/cron-expression.js.map +1 -0
- package/dist/components/cron-editor/index.d.ts +3 -0
- package/dist/components/cron-editor/index.js +3 -0
- package/dist/components/cron-editor/meta.js +17 -0
- package/dist/components/cron-editor/meta.js.map +1 -0
- package/dist/components/date-picker/date-picker.js +1 -1
- package/dist/components/diff-viewer/diff-model.d.ts +69 -0
- package/dist/components/diff-viewer/diff-model.d.ts.map +1 -0
- package/dist/components/diff-viewer/diff-model.js +180 -0
- package/dist/components/diff-viewer/diff-model.js.map +1 -0
- package/dist/components/diff-viewer/diff-viewer.d.ts +42 -0
- package/dist/components/diff-viewer/diff-viewer.d.ts.map +1 -0
- package/dist/components/diff-viewer/diff-viewer.js +212 -0
- package/dist/components/diff-viewer/diff-viewer.js.map +1 -0
- package/dist/components/diff-viewer/index.d.ts +3 -0
- package/dist/components/diff-viewer/index.js +3 -0
- package/dist/components/diff-viewer/meta.js +17 -0
- package/dist/components/diff-viewer/meta.js.map +1 -0
- package/dist/components/dns-record/dns-record.d.ts +61 -0
- package/dist/components/dns-record/dns-record.d.ts.map +1 -0
- package/dist/components/dns-record/dns-record.js +227 -0
- package/dist/components/dns-record/dns-record.js.map +1 -0
- package/dist/components/dns-record/index.d.ts +2 -0
- package/dist/components/dns-record/index.js +2 -0
- package/dist/components/dns-record/meta.js +17 -0
- package/dist/components/dns-record/meta.js.map +1 -0
- package/dist/components/log-viewer/index.d.ts +3 -0
- package/dist/components/log-viewer/index.js +3 -0
- package/dist/components/log-viewer/log-stream.d.ts +68 -0
- package/dist/components/log-viewer/log-stream.d.ts.map +1 -0
- package/dist/components/log-viewer/log-stream.js +120 -0
- package/dist/components/log-viewer/log-stream.js.map +1 -0
- package/dist/components/log-viewer/log-viewer.d.ts +51 -0
- package/dist/components/log-viewer/log-viewer.d.ts.map +1 -0
- package/dist/components/log-viewer/log-viewer.js +284 -0
- package/dist/components/log-viewer/log-viewer.js.map +1 -0
- package/dist/components/log-viewer/meta.js +17 -0
- package/dist/components/log-viewer/meta.js.map +1 -0
- package/dist/components/permission-matrix/index.d.ts +3 -0
- package/dist/components/permission-matrix/index.js +3 -0
- package/dist/components/permission-matrix/meta.js +17 -0
- package/dist/components/permission-matrix/meta.js.map +1 -0
- package/dist/components/permission-matrix/permission-matrix.d.ts +46 -0
- package/dist/components/permission-matrix/permission-matrix.d.ts.map +1 -0
- package/dist/components/permission-matrix/permission-matrix.js +199 -0
- package/dist/components/permission-matrix/permission-matrix.js.map +1 -0
- package/dist/components/permission-matrix/permission-model.d.ts +53 -0
- package/dist/components/permission-matrix/permission-model.d.ts.map +1 -0
- package/dist/components/permission-matrix/permission-model.js +64 -0
- package/dist/components/permission-matrix/permission-model.js.map +1 -0
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/secret-field/index.d.ts +2 -0
- package/dist/components/secret-field/index.js +2 -0
- package/dist/components/secret-field/meta.js +17 -0
- package/dist/components/secret-field/meta.js.map +1 -0
- package/dist/components/secret-field/secret-field.d.ts +58 -0
- package/dist/components/secret-field/secret-field.d.ts.map +1 -0
- package/dist/components/secret-field/secret-field.js +168 -0
- package/dist/components/secret-field/secret-field.js.map +1 -0
- package/dist/components/session-expiry/index.d.ts +2 -0
- package/dist/components/session-expiry/index.js +2 -0
- package/dist/components/session-expiry/meta.js +17 -0
- package/dist/components/session-expiry/meta.js.map +1 -0
- package/dist/components/session-expiry/session-expiry.d.ts +64 -0
- package/dist/components/session-expiry/session-expiry.d.ts.map +1 -0
- package/dist/components/session-expiry/session-expiry.js +185 -0
- package/dist/components/session-expiry/session-expiry.js.map +1 -0
- package/dist/components/shortcut-recorder/index.d.ts +3 -0
- package/dist/components/shortcut-recorder/index.js +3 -0
- package/dist/components/shortcut-recorder/meta.js +17 -0
- package/dist/components/shortcut-recorder/meta.js.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-model.d.ts +67 -0
- package/dist/components/shortcut-recorder/shortcut-model.d.ts.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-model.js +168 -0
- package/dist/components/shortcut-recorder/shortcut-model.js.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.d.ts +47 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.d.ts.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.js +212 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.js.map +1 -0
- package/dist/components/spinner/spinner.d.ts +1 -1
- package/dist/components/sync-status/index.d.ts +2 -0
- package/dist/components/sync-status/index.js +2 -0
- package/dist/components/sync-status/meta.js +17 -0
- package/dist/components/sync-status/meta.js.map +1 -0
- package/dist/components/sync-status/sync-status.d.ts +65 -0
- package/dist/components/sync-status/sync-status.d.ts.map +1 -0
- package/dist/components/sync-status/sync-status.js +103 -0
- package/dist/components/sync-status/sync-status.js.map +1 -0
- package/dist/components/time-range-picker/index.d.ts +3 -0
- package/dist/components/time-range-picker/index.js +3 -0
- package/dist/components/time-range-picker/meta.js +22 -0
- package/dist/components/time-range-picker/meta.js.map +1 -0
- package/dist/components/time-range-picker/time-expression.d.ts +88 -0
- package/dist/components/time-range-picker/time-expression.d.ts.map +1 -0
- package/dist/components/time-range-picker/time-expression.js +335 -0
- package/dist/components/time-range-picker/time-expression.js.map +1 -0
- package/dist/components/time-range-picker/time-range-picker.d.ts +65 -0
- package/dist/components/time-range-picker/time-range-picker.d.ts.map +1 -0
- package/dist/components/time-range-picker/time-range-picker.js +377 -0
- package/dist/components/time-range-picker/time-range-picker.js.map +1 -0
- package/dist/components/toast/toast.d.ts +1 -1
- package/dist/index.d.ts +36 -1
- package/dist/index.js +24 -3
- package/dist/registry/components.d.ts.map +1 -1
- package/dist/registry/components.js +78 -50
- package/dist/registry/components.js.map +1 -1
- package/package.json +6 -4
- package/src/components/ai-disclosure/ai-disclosure.tsx +244 -0
- package/src/components/ai-disclosure/index.ts +9 -0
- package/src/components/ai-disclosure/meta.ts +23 -0
- package/src/components/ai-extraction-review/ai-extraction-review.tsx +492 -0
- package/src/components/ai-extraction-review/extraction-model.ts +159 -0
- package/src/components/ai-extraction-review/index.ts +13 -0
- package/src/components/ai-extraction-review/meta.ts +24 -0
- package/src/components/ai-suggested-value/ai-suggested-value.tsx +295 -0
- package/src/components/ai-suggested-value/index.ts +6 -0
- package/src/components/ai-suggested-value/meta.ts +24 -0
- package/src/components/confirm-typed/confirm-typed.tsx +191 -0
- package/src/components/confirm-typed/index.ts +1 -0
- package/src/components/confirm-typed/meta.ts +22 -0
- package/src/components/cron-editor/cron-editor.tsx +676 -0
- package/src/components/cron-editor/cron-expression.ts +552 -0
- package/src/components/cron-editor/index.ts +23 -0
- package/src/components/cron-editor/meta.ts +23 -0
- package/src/components/diff-viewer/diff-model.ts +256 -0
- package/src/components/diff-viewer/diff-viewer.tsx +339 -0
- package/src/components/diff-viewer/index.ts +17 -0
- package/src/components/diff-viewer/meta.ts +20 -0
- package/src/components/dns-record/dns-record.tsx +315 -0
- package/src/components/dns-record/index.ts +7 -0
- package/src/components/dns-record/meta.ts +21 -0
- package/src/components/log-viewer/index.ts +21 -0
- package/src/components/log-viewer/log-stream.ts +176 -0
- package/src/components/log-viewer/log-viewer.tsx +424 -0
- package/src/components/log-viewer/meta.ts +21 -0
- package/src/components/permission-matrix/index.ts +14 -0
- package/src/components/permission-matrix/meta.ts +23 -0
- package/src/components/permission-matrix/permission-matrix.tsx +368 -0
- package/src/components/permission-matrix/permission-model.ts +120 -0
- package/src/components/secret-field/index.ts +6 -0
- package/src/components/secret-field/meta.ts +21 -0
- package/src/components/secret-field/secret-field.tsx +284 -0
- package/src/components/session-expiry/index.ts +9 -0
- package/src/components/session-expiry/meta.ts +23 -0
- package/src/components/session-expiry/session-expiry.tsx +321 -0
- package/src/components/shortcut-recorder/index.ts +19 -0
- package/src/components/shortcut-recorder/meta.ts +22 -0
- package/src/components/shortcut-recorder/shortcut-model.ts +204 -0
- package/src/components/shortcut-recorder/shortcut-recorder.tsx +336 -0
- package/src/components/sync-status/index.ts +9 -0
- package/src/components/sync-status/meta.ts +21 -0
- package/src/components/sync-status/sync-status.tsx +217 -0
- package/src/components/time-range-picker/index.ts +25 -0
- package/src/components/time-range-picker/meta.ts +22 -0
- package/src/components/time-range-picker/time-expression.ts +491 -0
- package/src/components/time-range-picker/time-range-picker.tsx +522 -0
- package/src/index.ts +14 -0
- package/src/registry/components.ts +28 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { disabledStyles, focusRing } from "../../lib/styles.js";
|
|
3
|
+
import { cn } from "../../lib/utils.js";
|
|
4
|
+
import { describeShortcut, detectPlatform, needsModifier, parseShortcut, serializeShortcut, shortcutFromKey, shortcutParts, shortcutsEqual } from "./shortcut-model.js";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { useId, useState, useSyncExternalStore } from "react";
|
|
7
|
+
//#region src/components/shortcut-recorder/shortcut-recorder.tsx
|
|
8
|
+
const subscribeToNothing = () => () => void 0;
|
|
9
|
+
const readPlatform = () => detectPlatform();
|
|
10
|
+
const readNeutralPlatform = () => "other";
|
|
11
|
+
function ShortcutRecorder({ className, label, value: valueProp, defaultValue = null, onChange, taken = [], platform: platformProp, requireModifier = true, ...props }) {
|
|
12
|
+
const id = useId();
|
|
13
|
+
const detected = useSyncExternalStore(subscribeToNothing, readPlatform, readNeutralPlatform);
|
|
14
|
+
const platform = platformProp ?? detected;
|
|
15
|
+
const [uncontrolled, setUncontrolled] = useState(defaultValue);
|
|
16
|
+
const value = valueProp === void 0 ? uncontrolled : valueProp;
|
|
17
|
+
const current = value ? safeParse(value) : null;
|
|
18
|
+
const [recording, setRecording] = useState(false);
|
|
19
|
+
/** Modifiers held mid-chord, shown so the person sees the recorder listening. */
|
|
20
|
+
const [held, setHeld] = useState(null);
|
|
21
|
+
const [conflict, setConflict] = useState(null);
|
|
22
|
+
const [announcement, setAnnouncement] = useState("");
|
|
23
|
+
const commit = (next) => {
|
|
24
|
+
const serialized = next ? serializeShortcut(next) : null;
|
|
25
|
+
setUncontrolled(serialized);
|
|
26
|
+
onChange?.(serialized);
|
|
27
|
+
};
|
|
28
|
+
const stop = () => {
|
|
29
|
+
setRecording(false);
|
|
30
|
+
setHeld(null);
|
|
31
|
+
};
|
|
32
|
+
const onKeyDown = (event) => {
|
|
33
|
+
if (!recording) return;
|
|
34
|
+
if (event.key === "Tab") {
|
|
35
|
+
stop();
|
|
36
|
+
setAnnouncement("Cancelled.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
if (event.key === "Escape") {
|
|
41
|
+
stop();
|
|
42
|
+
setAnnouncement("Cancelled.");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if ((event.key === "Backspace" || event.key === "Delete") && !hasModifier(event)) {
|
|
46
|
+
stop();
|
|
47
|
+
commit(null);
|
|
48
|
+
setAnnouncement("Cleared.");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const chord = shortcutFromKey(event.nativeEvent, platform);
|
|
52
|
+
if (!chord) {
|
|
53
|
+
setHeld({
|
|
54
|
+
key: "",
|
|
55
|
+
mod: platform === "mac" ? event.metaKey : event.ctrlKey,
|
|
56
|
+
ctrl: platform === "mac" ? event.ctrlKey : false,
|
|
57
|
+
alt: event.altKey,
|
|
58
|
+
shift: event.shiftKey
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (requireModifier && needsModifier(chord)) {
|
|
63
|
+
setAnnouncement(`${describeShortcut(chord, platform)} needs a modifier — it would fire while typing. Try again.`);
|
|
64
|
+
setHeld(null);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const clash = taken.find((entry) => shortcutsEqual(safeParse(entry.shortcut) ?? chord, chord));
|
|
68
|
+
stop();
|
|
69
|
+
if (clash && !(current && shortcutsEqual(current, chord))) {
|
|
70
|
+
setConflict({
|
|
71
|
+
shortcut: chord,
|
|
72
|
+
with: clash
|
|
73
|
+
});
|
|
74
|
+
setAnnouncement(`${describeShortcut(chord, platform)} is already used by ${clash.label}.`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
commit(chord);
|
|
78
|
+
setAnnouncement(`Recorded ${describeShortcut(chord, platform)}.`);
|
|
79
|
+
};
|
|
80
|
+
const onKeyUp = (event) => {
|
|
81
|
+
if (!recording || !held) return;
|
|
82
|
+
setHeld({
|
|
83
|
+
key: "",
|
|
84
|
+
mod: platform === "mac" ? event.metaKey : event.ctrlKey,
|
|
85
|
+
ctrl: platform === "mac" ? event.ctrlKey : false,
|
|
86
|
+
alt: event.altKey,
|
|
87
|
+
shift: event.shiftKey
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
const shown = recording ? held : current;
|
|
91
|
+
const description = recording ? "Press the keys you want. Escape cancels, Backspace clears." : current ? `${describeShortcut(current, platform)}. Press to change.` : "Not set. Press to record.";
|
|
92
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
93
|
+
"data-slot": "shortcut-recorder",
|
|
94
|
+
"data-state": recording ? "recording" : conflict ? "conflict" : current ? "set" : "empty",
|
|
95
|
+
className: cn("flex flex-col gap-1.5", className),
|
|
96
|
+
...props,
|
|
97
|
+
children: [
|
|
98
|
+
/* @__PURE__ */ jsx("span", {
|
|
99
|
+
id: `${id}-label`,
|
|
100
|
+
className: "text-xs font-medium",
|
|
101
|
+
children: label
|
|
102
|
+
}),
|
|
103
|
+
/* @__PURE__ */ jsxs("div", {
|
|
104
|
+
className: "flex flex-wrap items-center gap-2",
|
|
105
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
106
|
+
type: "button",
|
|
107
|
+
"aria-labelledby": `${id}-label`,
|
|
108
|
+
"aria-describedby": `${id}-description`,
|
|
109
|
+
"aria-pressed": recording,
|
|
110
|
+
className: cn("flex h-9 min-w-32 items-center justify-center gap-1 rounded-md border px-3 font-mono text-sm", recording ? "border-primary bg-primary/5 text-muted-foreground" : "border-input bg-background hover:bg-accent", focusRing, disabledStyles),
|
|
111
|
+
onClick: () => {
|
|
112
|
+
if (recording) return;
|
|
113
|
+
setConflict(null);
|
|
114
|
+
setRecording(true);
|
|
115
|
+
setAnnouncement("");
|
|
116
|
+
},
|
|
117
|
+
onKeyDown,
|
|
118
|
+
onKeyUp,
|
|
119
|
+
onBlur: () => {
|
|
120
|
+
if (recording) {
|
|
121
|
+
stop();
|
|
122
|
+
setAnnouncement("Cancelled.");
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
126
|
+
"aria-hidden": "true",
|
|
127
|
+
className: "flex items-center gap-1",
|
|
128
|
+
children: shown && (shown.key || shown.mod || shown.ctrl || shown.alt || shown.shift) ? shortcutParts(shown, platform).filter(Boolean).map((part, index) => /* @__PURE__ */ jsx("kbd", {
|
|
129
|
+
className: "rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-xs",
|
|
130
|
+
children: part
|
|
131
|
+
}, index)) : recording ? /* @__PURE__ */ jsx("span", {
|
|
132
|
+
className: "text-xs",
|
|
133
|
+
children: "Press keys…"
|
|
134
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
135
|
+
className: "text-xs text-muted-foreground",
|
|
136
|
+
children: "Not set"
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
}), current && !recording ? /* @__PURE__ */ jsx("button", {
|
|
140
|
+
type: "button",
|
|
141
|
+
"aria-label": `Clear — ${label}`,
|
|
142
|
+
className: cn("rounded-md border border-input bg-background px-2.5 py-1 text-xs font-medium hover:bg-accent", focusRing),
|
|
143
|
+
onClick: () => {
|
|
144
|
+
commit(null);
|
|
145
|
+
setConflict(null);
|
|
146
|
+
setAnnouncement("Cleared.");
|
|
147
|
+
},
|
|
148
|
+
children: "Clear"
|
|
149
|
+
}) : null]
|
|
150
|
+
}),
|
|
151
|
+
/* @__PURE__ */ jsx("p", {
|
|
152
|
+
id: `${id}-description`,
|
|
153
|
+
className: "text-xs text-muted-foreground",
|
|
154
|
+
children: description
|
|
155
|
+
}),
|
|
156
|
+
conflict ? /* @__PURE__ */ jsxs("div", {
|
|
157
|
+
"data-slot": "shortcut-conflict",
|
|
158
|
+
className: "flex flex-wrap items-center gap-2 rounded-md border border-warning/50 bg-warning/5 px-2.5 py-1.5 text-xs",
|
|
159
|
+
children: [
|
|
160
|
+
/* @__PURE__ */ jsxs("span", {
|
|
161
|
+
className: "flex-1 text-warning",
|
|
162
|
+
children: [
|
|
163
|
+
describeShortcut(conflict.shortcut, platform),
|
|
164
|
+
" is already used by",
|
|
165
|
+
" ",
|
|
166
|
+
/* @__PURE__ */ jsx("strong", { children: conflict.with.label }),
|
|
167
|
+
"."
|
|
168
|
+
]
|
|
169
|
+
}),
|
|
170
|
+
/* @__PURE__ */ jsx("button", {
|
|
171
|
+
type: "button",
|
|
172
|
+
className: cn("rounded-md border border-input bg-background px-2 py-0.5 font-medium hover:bg-accent", focusRing),
|
|
173
|
+
onClick: () => {
|
|
174
|
+
commit(conflict.shortcut);
|
|
175
|
+
setConflict(null);
|
|
176
|
+
setAnnouncement(`Recorded ${describeShortcut(conflict.shortcut, platform)}. ${conflict.with.label} no longer has a shortcut.`);
|
|
177
|
+
},
|
|
178
|
+
children: "Use anyway"
|
|
179
|
+
}),
|
|
180
|
+
/* @__PURE__ */ jsx("button", {
|
|
181
|
+
type: "button",
|
|
182
|
+
className: cn("rounded-md border border-input bg-background px-2 py-0.5 font-medium hover:bg-accent", focusRing),
|
|
183
|
+
onClick: () => {
|
|
184
|
+
setConflict(null);
|
|
185
|
+
},
|
|
186
|
+
children: "Keep the old one"
|
|
187
|
+
})
|
|
188
|
+
]
|
|
189
|
+
}) : null,
|
|
190
|
+
/* @__PURE__ */ jsx("span", {
|
|
191
|
+
role: "status",
|
|
192
|
+
"aria-live": "polite",
|
|
193
|
+
className: "sr-only",
|
|
194
|
+
children: announcement
|
|
195
|
+
})
|
|
196
|
+
]
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function hasModifier(event) {
|
|
200
|
+
return event.metaKey || event.ctrlKey || event.altKey || event.shiftKey;
|
|
201
|
+
}
|
|
202
|
+
function safeParse(text) {
|
|
203
|
+
try {
|
|
204
|
+
return parseShortcut(text);
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
export { ShortcutRecorder };
|
|
211
|
+
|
|
212
|
+
//# sourceMappingURL=shortcut-recorder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shortcut-recorder.js","names":[],"sources":["../../../src/components/shortcut-recorder/shortcut-recorder.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n useId,\n useState,\n useSyncExternalStore,\n type ComponentPropsWithRef,\n type KeyboardEvent,\n} from \"react\";\n\nimport { disabledStyles, focusRing } from \"@/lib/styles\";\nimport { cn } from \"@/lib/utils\";\n\nimport {\n describeShortcut,\n detectPlatform,\n needsModifier,\n parseShortcut,\n serializeShortcut,\n shortcutFromKey,\n shortcutParts,\n shortcutsEqual,\n type Platform,\n type Shortcut,\n} from \"./shortcut-model\";\n\n/**\n * Press the keys you want.\n *\n * Linear, Slack, VS Code, Figma and Superhuman each wrote one, and no\n * component library ships the recorder — only the `<kbd>` that displays the\n * result. The recorder is the hard part: a button that, when pressed, stops\n * being a button and starts being a keyboard listener, and has to come back.\n *\n * Three things it does that a listener bolted onto an input does not. It\n * reads letters from `code`, not `key`, so Option-K records as Option K and\n * not as the ˚ the Mac produced. It stores `Mod`, not Command or Control,\n * so a binding saved on one machine is right on the other. And it refuses a\n * bare printable key by saying so, because a shortcut that fires while\n * someone types a sentence is the bug every app that allowed it later fixed.\n *\n * Tab and Escape are never recorded: Tab leaves, Escape cancels, and a\n * recorder that captures both is a keyboard trap with a nice label. A\n * chord that another command already uses is said, with that command's\n * name, and applied only if the person says to use it anyway.\n */\n\nexport interface TakenShortcut {\n /** Stored form: `Mod+K`. */\n shortcut: string;\n /** What it does: \"Search\". */\n label: string;\n}\n\nexport interface ShortcutRecorderProps extends Omit<\n ComponentPropsWithRef<\"div\">,\n \"onChange\" | \"defaultValue\"\n> {\n /** What the shortcut triggers: \"Open search\". Names the control. */\n label: string;\n /** Stored form, `Mod+Shift+K`, or null for none. Controlled. */\n value?: string | null;\n defaultValue?: string | null;\n onChange?: (value: string | null) => void;\n /** Shortcuts other commands already use. A clash is said, and applied only on request. */\n taken?: TakenShortcut[];\n /** Mac shows ⌘⇧K; everything else shows Ctrl+Shift+K. Detected if omitted. */\n platform?: Platform;\n /** Printable keys need a modifier. On by default, for the reason in the source. */\n requireModifier?: boolean;\n}\n\n/* The platform, as an external store that never changes: a constant that the\n server cannot know, so the server snapshot is the neutral one and the real\n answer arrives after hydration without a mismatch. */\nconst subscribeToNothing = () => () => undefined;\nconst readPlatform = () => detectPlatform();\nconst readNeutralPlatform = (): Platform => \"other\";\n\nexport function ShortcutRecorder({\n className,\n label,\n value: valueProp,\n defaultValue = null,\n onChange,\n taken = [],\n platform: platformProp,\n requireModifier = true,\n ...props\n}: ShortcutRecorderProps) {\n const id = useId();\n const detected = useSyncExternalStore(subscribeToNothing, readPlatform, readNeutralPlatform);\n const platform = platformProp ?? detected;\n\n const [uncontrolled, setUncontrolled] = useState<string | null>(defaultValue);\n const value = valueProp === undefined ? uncontrolled : valueProp;\n const current = value ? safeParse(value) : null;\n\n const [recording, setRecording] = useState(false);\n /** Modifiers held mid-chord, shown so the person sees the recorder listening. */\n const [held, setHeld] = useState<Shortcut | null>(null);\n const [conflict, setConflict] = useState<{ shortcut: Shortcut; with: TakenShortcut } | null>(\n null,\n );\n const [announcement, setAnnouncement] = useState(\"\");\n\n const commit = (next: Shortcut | null) => {\n const serialized = next ? serializeShortcut(next) : null;\n setUncontrolled(serialized);\n onChange?.(serialized);\n };\n\n const stop = () => {\n setRecording(false);\n setHeld(null);\n };\n\n const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n if (!recording) return;\n // Tab leaves. A recorder that keeps it is a keyboard trap.\n if (event.key === \"Tab\") {\n stop();\n setAnnouncement(\"Cancelled.\");\n return;\n }\n event.preventDefault();\n if (event.key === \"Escape\") {\n stop();\n setAnnouncement(\"Cancelled.\");\n return;\n }\n if ((event.key === \"Backspace\" || event.key === \"Delete\") && !hasModifier(event)) {\n stop();\n commit(null);\n setAnnouncement(\"Cleared.\");\n return;\n }\n\n const chord = shortcutFromKey(event.nativeEvent, platform);\n if (!chord) {\n // Only modifiers so far. Show them, keep listening.\n setHeld({\n key: \"\",\n mod: platform === \"mac\" ? event.metaKey : event.ctrlKey,\n ctrl: platform === \"mac\" ? event.ctrlKey : false,\n alt: event.altKey,\n shift: event.shiftKey,\n });\n return;\n }\n\n if (requireModifier && needsModifier(chord)) {\n setAnnouncement(\n `${describeShortcut(chord, platform)} needs a modifier — it would fire while typing. Try again.`,\n );\n setHeld(null);\n return;\n }\n\n const clash = taken.find((entry) =>\n shortcutsEqual(safeParse(entry.shortcut) ?? chord, chord),\n );\n stop();\n if (clash && !(current && shortcutsEqual(current, chord))) {\n setConflict({ shortcut: chord, with: clash });\n setAnnouncement(\n `${describeShortcut(chord, platform)} is already used by ${clash.label}.`,\n );\n return;\n }\n commit(chord);\n setAnnouncement(`Recorded ${describeShortcut(chord, platform)}.`);\n };\n\n const onKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {\n if (!recording || !held) return;\n setHeld({\n key: \"\",\n mod: platform === \"mac\" ? event.metaKey : event.ctrlKey,\n ctrl: platform === \"mac\" ? event.ctrlKey : false,\n alt: event.altKey,\n shift: event.shiftKey,\n });\n };\n\n const shown = recording ? held : current;\n const description = recording\n ? \"Press the keys you want. Escape cancels, Backspace clears.\"\n : current\n ? `${describeShortcut(current, platform)}. Press to change.`\n : \"Not set. Press to record.\";\n\n return (\n <div\n data-slot=\"shortcut-recorder\"\n data-state={recording ? \"recording\" : conflict ? \"conflict\" : current ? \"set\" : \"empty\"}\n className={cn(\"flex flex-col gap-1.5\", className)}\n {...props}\n >\n <span id={`${id}-label`} className=\"text-xs font-medium\">\n {label}\n </span>\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <button\n type=\"button\"\n aria-labelledby={`${id}-label`}\n aria-describedby={`${id}-description`}\n aria-pressed={recording}\n className={cn(\n \"flex h-9 min-w-32 items-center justify-center gap-1 rounded-md border px-3 font-mono text-sm\",\n recording\n ? \"border-primary bg-primary/5 text-muted-foreground\"\n : \"border-input bg-background hover:bg-accent\",\n focusRing,\n disabledStyles,\n )}\n onClick={() => {\n if (recording) return;\n setConflict(null);\n setRecording(true);\n setAnnouncement(\"\");\n }}\n onKeyDown={onKeyDown}\n onKeyUp={onKeyUp}\n onBlur={() => {\n if (recording) {\n stop();\n setAnnouncement(\"Cancelled.\");\n }\n }}\n >\n {/* Symbols and key caps are for the eye; the description says the\n same thing in words. */}\n <span aria-hidden=\"true\" className=\"flex items-center gap-1\">\n {shown && (shown.key || shown.mod || shown.ctrl || shown.alt || shown.shift) ? (\n shortcutParts(shown, platform)\n .filter(Boolean)\n .map((part, index) => (\n <kbd\n key={index}\n className=\"rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-xs\"\n >\n {part}\n </kbd>\n ))\n ) : recording ? (\n <span className=\"text-xs\">Press keys…</span>\n ) : (\n <span className=\"text-xs text-muted-foreground\">Not set</span>\n )}\n </span>\n </button>\n\n {current && !recording ? (\n <button\n type=\"button\"\n aria-label={`Clear — ${label}`}\n className={cn(\n \"rounded-md border border-input bg-background px-2.5 py-1 text-xs font-medium hover:bg-accent\",\n focusRing,\n )}\n onClick={() => {\n commit(null);\n setConflict(null);\n setAnnouncement(\"Cleared.\");\n }}\n >\n Clear\n </button>\n ) : null}\n </div>\n\n <p id={`${id}-description`} className=\"text-xs text-muted-foreground\">\n {description}\n </p>\n\n {conflict ? (\n <div\n data-slot=\"shortcut-conflict\"\n className=\"flex flex-wrap items-center gap-2 rounded-md border border-warning/50 bg-warning/5 px-2.5 py-1.5 text-xs\"\n >\n <span className=\"flex-1 text-warning\">\n {describeShortcut(conflict.shortcut, platform)} is already used by{\" \"}\n <strong>{conflict.with.label}</strong>.\n </span>\n <button\n type=\"button\"\n className={cn(\n \"rounded-md border border-input bg-background px-2 py-0.5 font-medium hover:bg-accent\",\n focusRing,\n )}\n onClick={() => {\n commit(conflict.shortcut);\n setConflict(null);\n setAnnouncement(\n `Recorded ${describeShortcut(conflict.shortcut, platform)}. ${conflict.with.label} no longer has a shortcut.`,\n );\n }}\n >\n Use anyway\n </button>\n <button\n type=\"button\"\n className={cn(\n \"rounded-md border border-input bg-background px-2 py-0.5 font-medium hover:bg-accent\",\n focusRing,\n )}\n onClick={() => {\n setConflict(null);\n }}\n >\n Keep the old one\n </button>\n </div>\n ) : null}\n\n {/* Present from the start, so the first outcome is heard. */}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n {announcement}\n </span>\n </div>\n );\n}\n\nfunction hasModifier(event: KeyboardEvent): boolean {\n return event.metaKey || event.ctrlKey || event.altKey || event.shiftKey;\n}\n\nfunction safeParse(text: string): Shortcut | null {\n try {\n return parseShortcut(text);\n } catch {\n return null;\n }\n}\n"],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@ import { VariantProps } from "class-variance-authority";
|
|
|
2
2
|
import { ComponentPropsWithRef } from "react";
|
|
3
3
|
//#region src/components/spinner/spinner.d.ts
|
|
4
4
|
declare const spinnerVariants: (props?: ({
|
|
5
|
-
size?: "
|
|
5
|
+
size?: "xs" | "sm" | "md" | "lg" | "xl" | null | undefined;
|
|
6
6
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
7
7
|
interface SpinnerProps extends Omit<ComponentPropsWithRef<"svg">, "children">, VariantProps<typeof spinnerVariants> {
|
|
8
8
|
/**
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineMeta } from "../../registry/schema.js";
|
|
2
|
+
//#region src/components/sync-status/meta.ts
|
|
3
|
+
const meta = defineMeta({
|
|
4
|
+
name: "sync-status",
|
|
5
|
+
title: "Sync Status",
|
|
6
|
+
description: "Offline, saving, saved or failed — with the count of changes being held.",
|
|
7
|
+
category: "feedback",
|
|
8
|
+
status: "stable",
|
|
9
|
+
dependencies: [],
|
|
10
|
+
registryDependencies: ["button"],
|
|
11
|
+
files: ["sync-status.tsx"],
|
|
12
|
+
a11y: "The word is the status and the coloured dot is decoration, hidden from assistive technology. The visible text is not a live region: every save flips it and a region on it would narrate the whole session. A separate region, present from the first render, announces transitions only — went offline with what will happen to the changes, came back with what is being saved, could not save, saved after a failure — and never the churn of saving itself. Announcing is on by default because going offline is the one thing a reader who is typing has to be told, and it can be turned off. The server render assumes online, since a page with no interface to report has no business saying offline."
|
|
13
|
+
});
|
|
14
|
+
//#endregion
|
|
15
|
+
export { meta };
|
|
16
|
+
|
|
17
|
+
//# sourceMappingURL=meta.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"meta.js","names":[],"sources":["../../../src/components/sync-status/meta.ts"],"sourcesContent":["import { defineMeta } from \"@/registry/schema\";\n\nexport const meta = defineMeta({\n name: \"sync-status\",\n title: \"Sync Status\",\n description: \"Offline, saving, saved or failed — with the count of changes being held.\",\n category: \"feedback\",\n status: \"stable\",\n dependencies: [],\n registryDependencies: [\"button\"],\n files: [\"sync-status.tsx\"],\n a11y:\n \"The word is the status and the coloured dot is decoration, hidden from assistive \" +\n \"technology. The visible text is not a live region: every save flips it and a region on it \" +\n \"would narrate the whole session. A separate region, present from the first render, \" +\n \"announces transitions only — went offline with what will happen to the changes, came back \" +\n \"with what is being saved, could not save, saved after a failure — and never the churn of \" +\n \"saving itself. Announcing is on by default because going offline is the one thing a reader \" +\n \"who is typing has to be told, and it can be turned off. The server render assumes online, \" +\n \"since a page with no interface to report has no business saying offline.\",\n});\n"],"mappings":";;AAEA,MAAa,OAAO,WAAW;CAC7B,MAAM;CACN,OAAO;CACP,aAAa;CACb,UAAU;CACV,QAAQ;CACR,cAAc,CAAC;CACf,sBAAsB,CAAC,QAAQ;CAC/B,OAAO,CAAC,iBAAiB;CACzB,MACE;AAQJ,CAAC"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ComponentPropsWithRef } from "react";
|
|
2
|
+
//#region src/components/sync-status/sync-status.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* "Offline — 3 changes will sync when you're back."
|
|
5
|
+
*
|
|
6
|
+
* Linear, Notion, Figma and Google Docs each built this, and nothing in a
|
|
7
|
+
* component library touches network state at all. It is small, and it is
|
|
8
|
+
* the difference between an app that loses work and one that says it is
|
|
9
|
+
* holding it: the moment a request fails, the reader needs to know whether
|
|
10
|
+
* to keep typing, and whether what they typed a minute ago is anywhere.
|
|
11
|
+
*
|
|
12
|
+
* Two things this gets right that the obvious version gets wrong.
|
|
13
|
+
*
|
|
14
|
+
* `navigator.onLine` is believed in one direction only. False is reliable —
|
|
15
|
+
* the interface is down. True means only that there is an interface, not
|
|
16
|
+
* that the server is reachable, so an application's own failed request is
|
|
17
|
+
* the real signal and takes precedence. The hook is exported for the rest
|
|
18
|
+
* of the app to share the same reading.
|
|
19
|
+
*
|
|
20
|
+
* Announcements are for transitions, not states. Every save flips
|
|
21
|
+
* "Saving…" to "Saved", and a live region on that text narrates the whole
|
|
22
|
+
* session. So the visible text is not live; a separate region says something
|
|
23
|
+
* only when the situation changes — went offline, came back, could not sync —
|
|
24
|
+
* which is when a reader who is typing needs to be told.
|
|
25
|
+
*/
|
|
26
|
+
type SyncState = "synced" | "pending" | "syncing" | "offline" | "error";
|
|
27
|
+
interface SyncInput {
|
|
28
|
+
/** From `useOnlineStatus`, or your own reading. */
|
|
29
|
+
online: boolean;
|
|
30
|
+
/** Changes waiting to be sent. */
|
|
31
|
+
pending?: number;
|
|
32
|
+
/** A send is in flight. */
|
|
33
|
+
syncing?: boolean;
|
|
34
|
+
/** The last send failed, in the reader's language. Takes precedence over `online`. */
|
|
35
|
+
error?: string | null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Which state the inputs amount to. Error beats offline beats syncing beats
|
|
39
|
+
* pending beats synced: each one is the thing the reader most needs to know
|
|
40
|
+
* given the others.
|
|
41
|
+
*/
|
|
42
|
+
declare function syncState({ online, pending, syncing, error }: SyncInput): SyncState;
|
|
43
|
+
/** The state as a sentence. Used for the visible text and the announcement. */
|
|
44
|
+
declare function describeSyncState(state: SyncState, pending?: number, error?: string | null): string;
|
|
45
|
+
/** Whether the browser believes it has a network. False is reliable; true is not. */
|
|
46
|
+
declare function useOnlineStatus(): boolean;
|
|
47
|
+
interface SyncStatusProps extends Omit<ComponentPropsWithRef<"div">, "children">, Omit<SyncInput, "online"> {
|
|
48
|
+
/** Omit to read the browser's own status. */
|
|
49
|
+
online?: boolean;
|
|
50
|
+
/** When the last save succeeded. Shown as a time, not a relative phrase, so no clock is needed. */
|
|
51
|
+
lastSyncedAt?: Date;
|
|
52
|
+
locale?: string;
|
|
53
|
+
/** Offered when a save failed. */
|
|
54
|
+
onRetry?: () => void;
|
|
55
|
+
/**
|
|
56
|
+
* Announce transitions. On by default, because going offline is the one
|
|
57
|
+
* thing a reader who is typing has to be told; syncing itself never
|
|
58
|
+
* announces, whichever way this is set.
|
|
59
|
+
*/
|
|
60
|
+
announce?: boolean;
|
|
61
|
+
}
|
|
62
|
+
declare function SyncStatus({ className, online: onlineProp, pending, syncing, error, lastSyncedAt, locale, onRetry, announce, ...props }: SyncStatusProps): import("react").JSX.Element;
|
|
63
|
+
//#endregion
|
|
64
|
+
export { SyncInput, SyncState, SyncStatus, SyncStatusProps, describeSyncState, syncState, useOnlineStatus };
|
|
65
|
+
//# sourceMappingURL=sync-status.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-status.d.ts","names":[],"sources":["../../../src/components/sync-status/sync-status.tsx"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;KA+BY;UAEK;;EAEf;;EAEA;;EAEA;;EAEA;;;;;;;iBAQc,YACd,QACA,SACA,SACA,SACC,YAAY;;iBAWC,kBACd,OAAO,WACP,kBACA;;iBAmCc;UAIC,wBACP,KAAK,2CAA2C,KAAK;;EAE7D;;EAEA,eAAe;EACf;;EAEA;;;;;;EAMA;;iBAGc,aACd,WACA,QAAQ,YACR,SACA,SACA,OACA,cACA,QACA,SACA,aACG,SACF,kCAAe,IAAA"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../../lib/utils.js";
|
|
3
|
+
import { Button } from "../button/button.js";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { useState, useSyncExternalStore } from "react";
|
|
6
|
+
//#region src/components/sync-status/sync-status.tsx
|
|
7
|
+
/**
|
|
8
|
+
* Which state the inputs amount to. Error beats offline beats syncing beats
|
|
9
|
+
* pending beats synced: each one is the thing the reader most needs to know
|
|
10
|
+
* given the others.
|
|
11
|
+
*/
|
|
12
|
+
function syncState({ online, pending = 0, syncing = false, error }) {
|
|
13
|
+
if (error) return "error";
|
|
14
|
+
if (!online) return "offline";
|
|
15
|
+
if (syncing) return "syncing";
|
|
16
|
+
if (pending > 0) return "pending";
|
|
17
|
+
return "synced";
|
|
18
|
+
}
|
|
19
|
+
const changes = (n) => `${String(n)} ${n === 1 ? "change" : "changes"}`;
|
|
20
|
+
/** The state as a sentence. Used for the visible text and the announcement. */
|
|
21
|
+
function describeSyncState(state, pending = 0, error) {
|
|
22
|
+
switch (state) {
|
|
23
|
+
case "synced": return "All changes saved";
|
|
24
|
+
case "pending": return `${changes(pending)} waiting to save`;
|
|
25
|
+
case "syncing": return pending > 0 ? `Saving ${changes(pending)}…` : "Saving…";
|
|
26
|
+
case "offline": return pending > 0 ? `Offline. ${changes(pending)} will save when you're back online.` : "Offline. Changes will save when you're back online.";
|
|
27
|
+
case "error": return pending > 0 ? `Could not save ${changes(pending)}. ${error ?? ""}`.trim() : `Could not save. ${error ?? ""}`.trim();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function subscribeOnline(callback) {
|
|
31
|
+
window.addEventListener("online", callback);
|
|
32
|
+
window.addEventListener("offline", callback);
|
|
33
|
+
return () => {
|
|
34
|
+
window.removeEventListener("online", callback);
|
|
35
|
+
window.removeEventListener("offline", callback);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const readOnline = () => navigator.onLine;
|
|
39
|
+
const readOnlineOnServer = () => true;
|
|
40
|
+
/** Whether the browser believes it has a network. False is reliable; true is not. */
|
|
41
|
+
function useOnlineStatus() {
|
|
42
|
+
return useSyncExternalStore(subscribeOnline, readOnline, readOnlineOnServer);
|
|
43
|
+
}
|
|
44
|
+
function SyncStatus({ className, online: onlineProp, pending = 0, syncing = false, error, lastSyncedAt, locale, onRetry, announce = true, ...props }) {
|
|
45
|
+
const browserOnline = useOnlineStatus();
|
|
46
|
+
const state = syncState({
|
|
47
|
+
online: onlineProp ?? browserOnline,
|
|
48
|
+
pending,
|
|
49
|
+
syncing,
|
|
50
|
+
error
|
|
51
|
+
});
|
|
52
|
+
const text = describeSyncState(state, pending, error);
|
|
53
|
+
const [seen, setSeen] = useState(state);
|
|
54
|
+
const [announcement, setAnnouncement] = useState("");
|
|
55
|
+
const [recovering, setRecovering] = useState(false);
|
|
56
|
+
if (seen !== state) {
|
|
57
|
+
const from = seen;
|
|
58
|
+
setSeen(state);
|
|
59
|
+
if (state === "offline" || state === "error") {
|
|
60
|
+
setAnnouncement(text);
|
|
61
|
+
setRecovering(false);
|
|
62
|
+
} else if (from === "offline") setAnnouncement(pending > 0 ? `Back online. Saving ${changes(pending)}.` : "Back online.");
|
|
63
|
+
else if ((from === "error" || recovering) && state === "synced") {
|
|
64
|
+
setAnnouncement("Saved.");
|
|
65
|
+
setRecovering(false);
|
|
66
|
+
} else if (from === "error") setRecovering(true);
|
|
67
|
+
}
|
|
68
|
+
const savedAt = lastSyncedAt !== void 0 ? new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(lastSyncedAt) : null;
|
|
69
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
70
|
+
"data-slot": "sync-status",
|
|
71
|
+
"data-state": state,
|
|
72
|
+
className: cn("flex flex-wrap items-center gap-x-2 gap-y-1 text-xs", className),
|
|
73
|
+
...props,
|
|
74
|
+
children: [
|
|
75
|
+
/* @__PURE__ */ jsx("span", {
|
|
76
|
+
"aria-hidden": "true",
|
|
77
|
+
className: cn("size-2 shrink-0 rounded-full", state === "synced" && "bg-success", state === "pending" && "bg-muted-foreground", state === "syncing" && "animate-pulse-soft bg-info", state === "offline" && "bg-warning", state === "error" && "bg-destructive")
|
|
78
|
+
}),
|
|
79
|
+
/* @__PURE__ */ jsxs("span", {
|
|
80
|
+
"data-slot": "sync-status-text",
|
|
81
|
+
className: cn(state === "offline" && "text-warning", state === "error" && "text-destructive", (state === "synced" || state === "pending" || state === "syncing") && "text-muted-foreground"),
|
|
82
|
+
children: [text, savedAt && state === "synced" ? ` at ${savedAt}` : null]
|
|
83
|
+
}),
|
|
84
|
+
state === "error" && onRetry ? /* @__PURE__ */ jsx(Button, {
|
|
85
|
+
variant: "outline",
|
|
86
|
+
size: "sm",
|
|
87
|
+
className: "h-6 px-2 text-xs",
|
|
88
|
+
onClick: onRetry,
|
|
89
|
+
children: "Retry"
|
|
90
|
+
}) : null,
|
|
91
|
+
/* @__PURE__ */ jsx("span", {
|
|
92
|
+
role: "status",
|
|
93
|
+
"aria-live": announce ? "polite" : "off",
|
|
94
|
+
className: "sr-only",
|
|
95
|
+
children: announce ? announcement : ""
|
|
96
|
+
})
|
|
97
|
+
]
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
export { SyncStatus, describeSyncState, syncState, useOnlineStatus };
|
|
102
|
+
|
|
103
|
+
//# sourceMappingURL=sync-status.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-status.js","names":[],"sources":["../../../src/components/sync-status/sync-status.tsx"],"sourcesContent":["\"use client\";\n\nimport { useState, useSyncExternalStore, type ComponentPropsWithRef } from \"react\";\n\nimport { Button } from \"@/components/button\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * \"Offline — 3 changes will sync when you're back.\"\n *\n * Linear, Notion, Figma and Google Docs each built this, and nothing in a\n * component library touches network state at all. It is small, and it is\n * the difference between an app that loses work and one that says it is\n * holding it: the moment a request fails, the reader needs to know whether\n * to keep typing, and whether what they typed a minute ago is anywhere.\n *\n * Two things this gets right that the obvious version gets wrong.\n *\n * `navigator.onLine` is believed in one direction only. False is reliable —\n * the interface is down. True means only that there is an interface, not\n * that the server is reachable, so an application's own failed request is\n * the real signal and takes precedence. The hook is exported for the rest\n * of the app to share the same reading.\n *\n * Announcements are for transitions, not states. Every save flips\n * \"Saving…\" to \"Saved\", and a live region on that text narrates the whole\n * session. So the visible text is not live; a separate region says something\n * only when the situation changes — went offline, came back, could not sync —\n * which is when a reader who is typing needs to be told.\n */\n\nexport type SyncState = \"synced\" | \"pending\" | \"syncing\" | \"offline\" | \"error\";\n\nexport interface SyncInput {\n /** From `useOnlineStatus`, or your own reading. */\n online: boolean;\n /** Changes waiting to be sent. */\n pending?: number;\n /** A send is in flight. */\n syncing?: boolean;\n /** The last send failed, in the reader's language. Takes precedence over `online`. */\n error?: string | null;\n}\n\n/**\n * Which state the inputs amount to. Error beats offline beats syncing beats\n * pending beats synced: each one is the thing the reader most needs to know\n * given the others.\n */\nexport function syncState({\n online,\n pending = 0,\n syncing = false,\n error,\n}: SyncInput): SyncState {\n if (error) return \"error\";\n if (!online) return \"offline\";\n if (syncing) return \"syncing\";\n if (pending > 0) return \"pending\";\n return \"synced\";\n}\n\nconst changes = (n: number) => `${String(n)} ${n === 1 ? \"change\" : \"changes\"}`;\n\n/** The state as a sentence. Used for the visible text and the announcement. */\nexport function describeSyncState(\n state: SyncState,\n pending = 0,\n error?: string | null,\n): string {\n switch (state) {\n case \"synced\":\n return \"All changes saved\";\n case \"pending\":\n return `${changes(pending)} waiting to save`;\n case \"syncing\":\n return pending > 0 ? `Saving ${changes(pending)}…` : \"Saving…\";\n case \"offline\":\n return pending > 0\n ? `Offline. ${changes(pending)} will save when you're back online.`\n : \"Offline. Changes will save when you're back online.\";\n case \"error\":\n return pending > 0\n ? `Could not save ${changes(pending)}. ${error ?? \"\"}`.trim()\n : `Could not save. ${error ?? \"\"}`.trim();\n }\n}\n\n/* The browser's reading of the network, as an external store. The server\n snapshot is \"online\": a page rendered on the server has no interface to\n report, and rendering \"offline\" into HTML would be a lie until hydration. */\nfunction subscribeOnline(callback: () => void) {\n window.addEventListener(\"online\", callback);\n window.addEventListener(\"offline\", callback);\n return () => {\n window.removeEventListener(\"online\", callback);\n window.removeEventListener(\"offline\", callback);\n };\n}\nconst readOnline = () => navigator.onLine;\nconst readOnlineOnServer = () => true;\n\n/** Whether the browser believes it has a network. False is reliable; true is not. */\nexport function useOnlineStatus(): boolean {\n return useSyncExternalStore(subscribeOnline, readOnline, readOnlineOnServer);\n}\n\nexport interface SyncStatusProps\n extends Omit<ComponentPropsWithRef<\"div\">, \"children\">, Omit<SyncInput, \"online\"> {\n /** Omit to read the browser's own status. */\n online?: boolean;\n /** When the last save succeeded. Shown as a time, not a relative phrase, so no clock is needed. */\n lastSyncedAt?: Date;\n locale?: string;\n /** Offered when a save failed. */\n onRetry?: () => void;\n /**\n * Announce transitions. On by default, because going offline is the one\n * thing a reader who is typing has to be told; syncing itself never\n * announces, whichever way this is set.\n */\n announce?: boolean;\n}\n\nexport function SyncStatus({\n className,\n online: onlineProp,\n pending = 0,\n syncing = false,\n error,\n lastSyncedAt,\n locale,\n onRetry,\n announce = true,\n ...props\n}: SyncStatusProps) {\n const browserOnline = useOnlineStatus();\n const online = onlineProp ?? browserOnline;\n const state = syncState({ online, pending, syncing, error });\n const text = describeSyncState(state, pending, error);\n\n // Said once, when the situation changes. Adjusted during render so the\n // announcement lands in the same commit as the state it describes. Syncing\n // and pending are the ordinary churn of saving and are never announced.\n const [seen, setSeen] = useState(state);\n const [announcement, setAnnouncement] = useState(\"\");\n // A failure is over when a save lands, not when the retry starts, so the\n // recovery is remembered across the syncing in between.\n const [recovering, setRecovering] = useState(false);\n if (seen !== state) {\n const from = seen;\n setSeen(state);\n if (state === \"offline\" || state === \"error\") {\n setAnnouncement(text);\n setRecovering(false);\n } else if (from === \"offline\") {\n setAnnouncement(\n pending > 0 ? `Back online. Saving ${changes(pending)}.` : \"Back online.\",\n );\n } else if ((from === \"error\" || recovering) && state === \"synced\") {\n setAnnouncement(\"Saved.\");\n setRecovering(false);\n } else if (from === \"error\") {\n setRecovering(true);\n }\n }\n\n const savedAt =\n lastSyncedAt !== undefined\n ? new Intl.DateTimeFormat(locale, { timeStyle: \"short\" }).format(lastSyncedAt)\n : null;\n\n return (\n <div\n data-slot=\"sync-status\"\n data-state={state}\n className={cn(\"flex flex-wrap items-center gap-x-2 gap-y-1 text-xs\", className)}\n {...props}\n >\n {/* The dot is decoration. The word is the status. */}\n <span\n aria-hidden=\"true\"\n className={cn(\n \"size-2 shrink-0 rounded-full\",\n state === \"synced\" && \"bg-success\",\n state === \"pending\" && \"bg-muted-foreground\",\n state === \"syncing\" && \"animate-pulse-soft bg-info\",\n state === \"offline\" && \"bg-warning\",\n state === \"error\" && \"bg-destructive\",\n )}\n />\n <span\n data-slot=\"sync-status-text\"\n className={cn(\n state === \"offline\" && \"text-warning\",\n state === \"error\" && \"text-destructive\",\n (state === \"synced\" || state === \"pending\" || state === \"syncing\") &&\n \"text-muted-foreground\",\n )}\n >\n {text}\n {savedAt && state === \"synced\" ? ` at ${savedAt}` : null}\n </span>\n\n {state === \"error\" && onRetry ? (\n <Button variant=\"outline\" size=\"sm\" className=\"h-6 px-2 text-xs\" onClick={onRetry}>\n Retry\n </Button>\n ) : null}\n\n {/* Present from the start, so the first transition is heard. */}\n <span role=\"status\" aria-live={announce ? \"polite\" : \"off\"} className=\"sr-only\">\n {announce ? announcement : \"\"}\n </span>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { DEFAULT_PRESETS, ResolveOptions, ResolvedRange, TimeExpressionError, TimeRangePreset, TimeUnit, absoluteExpression, describeTimeRange, formatResolvedRange, isValidTimeRange, resolveExpression, resolveTimeRange } from "./time-expression.js";
|
|
2
|
+
import { TimeRange, TimeRangeCalendar, TimeRangeContent, TimeRangeContentProps, TimeRangeExpression, TimeRangePresets, TimeRangeProps, TimeRangeTrigger, TimeRangeTriggerProps } from "./time-range-picker.js";
|
|
3
|
+
export { DEFAULT_PRESETS, type ResolveOptions, type ResolvedRange, TimeExpressionError, TimeRange, TimeRangeCalendar, TimeRangeContent, type TimeRangeContentProps, TimeRangeExpression, type TimeRangePreset, TimeRangePresets, type TimeRangeProps, TimeRangeTrigger, type TimeRangeTriggerProps, type TimeUnit, absoluteExpression, describeTimeRange, formatResolvedRange, isValidTimeRange, resolveExpression, resolveTimeRange };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { DEFAULT_PRESETS, TimeExpressionError, absoluteExpression, describeTimeRange, formatResolvedRange, isValidTimeRange, resolveExpression, resolveTimeRange } from "./time-expression.js";
|
|
2
|
+
import { TimeRange, TimeRangeCalendar, TimeRangeContent, TimeRangeExpression, TimeRangePresets, TimeRangeTrigger } from "./time-range-picker.js";
|
|
3
|
+
export { DEFAULT_PRESETS, TimeExpressionError, TimeRange, TimeRangeCalendar, TimeRangeContent, TimeRangeExpression, TimeRangePresets, TimeRangeTrigger, absoluteExpression, describeTimeRange, formatResolvedRange, isValidTimeRange, resolveExpression, resolveTimeRange };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineMeta } from "../../registry/schema.js";
|
|
2
|
+
//#region src/components/time-range-picker/meta.ts
|
|
3
|
+
const meta = defineMeta({
|
|
4
|
+
name: "time-range-picker",
|
|
5
|
+
title: "Time Range Picker",
|
|
6
|
+
description: "A time range whose value is an expression, so it stays relative across a reload.",
|
|
7
|
+
category: "form",
|
|
8
|
+
status: "stable",
|
|
9
|
+
dependencies: ["react-day-picker"],
|
|
10
|
+
registryDependencies: [
|
|
11
|
+
"button",
|
|
12
|
+
"calendar",
|
|
13
|
+
"input",
|
|
14
|
+
"popover"
|
|
15
|
+
],
|
|
16
|
+
files: ["time-expression.ts", "time-range-picker.tsx"],
|
|
17
|
+
a11y: "The trigger is named by the range it holds — \"Last 6 hours\" — and the window that resolves to is read after it, so the relative choice is not replaced by two timestamps. The popover carries role=\"dialog\" and is named. Presets are buttons rather than radios because choosing one both selects and dismisses, with aria-pressed carrying which matches the current expression. The expression field reports why an unparseable entry is invalid through one aria-describedby element that the preview and the error share, so a reader hears the error replace the preview rather than both at once; an invalid expression is never applied, because a chart silently re-scoping itself is worse than one that refuses."
|
|
18
|
+
});
|
|
19
|
+
//#endregion
|
|
20
|
+
export { meta };
|
|
21
|
+
|
|
22
|
+
//# sourceMappingURL=meta.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"meta.js","names":[],"sources":["../../../src/components/time-range-picker/meta.ts"],"sourcesContent":["import { defineMeta } from \"@/registry/schema\";\n\nexport const meta = defineMeta({\n name: \"time-range-picker\",\n title: \"Time Range Picker\",\n description:\n \"A time range whose value is an expression, so it stays relative across a reload.\",\n category: \"form\",\n status: \"stable\",\n dependencies: [\"react-day-picker\"],\n registryDependencies: [\"button\", \"calendar\", \"input\", \"popover\"],\n files: [\"time-expression.ts\", \"time-range-picker.tsx\"],\n a11y:\n 'The trigger is named by the range it holds — \"Last 6 hours\" — and the window that ' +\n \"resolves to is read after it, so the relative choice is not replaced by two timestamps. \" +\n 'The popover carries role=\"dialog\" and is named. Presets are buttons rather than radios ' +\n \"because choosing one both selects and dismisses, with aria-pressed carrying which matches \" +\n \"the current expression. The expression field reports why an unparseable entry is invalid \" +\n \"through one aria-describedby element that the preview and the error share, so a reader \" +\n \"hears the error replace the preview rather than both at once; an invalid expression is \" +\n \"never applied, because a chart silently re-scoping itself is worse than one that refuses.\",\n});\n"],"mappings":";;AAEA,MAAa,OAAO,WAAW;CAC7B,MAAM;CACN,OAAO;CACP,aACE;CACF,UAAU;CACV,QAAQ;CACR,cAAc,CAAC,kBAAkB;CACjC,sBAAsB;EAAC;EAAU;EAAY;EAAS;CAAS;CAC/D,OAAO,CAAC,sBAAsB,uBAAuB;CACrD,MACE;AAQJ,CAAC"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/components/time-range-picker/time-expression.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Time ranges as expressions, not as two frozen timestamps.
|
|
4
|
+
*
|
|
5
|
+
* `now-6h` is still the last six hours tomorrow; a resolved pair is six hours
|
|
6
|
+
* of last Tuesday forever. Storing the expression is what lets a dashboard URL
|
|
7
|
+
* survive being bookmarked and reloaded.
|
|
8
|
+
*
|
|
9
|
+
* The grammar is a deliberate subset of the one Grafana, Datadog and Kibana
|
|
10
|
+
* converged on, so the strings are already familiar:
|
|
11
|
+
*
|
|
12
|
+
* range := expr ".." expr
|
|
13
|
+
* expr := "now" offset* snap? | ISO-8601
|
|
14
|
+
* offset := ("-" | "+") digits unit
|
|
15
|
+
* snap := "/" unit
|
|
16
|
+
* unit := s | m | h | d | w | M | y
|
|
17
|
+
*
|
|
18
|
+
* now-6h..now the last six hours
|
|
19
|
+
* now-6h/h..now the last six hours, starting on the hour
|
|
20
|
+
* now/d..now/d today, all of it
|
|
21
|
+
* now-1d/d..now-1d/d yesterday
|
|
22
|
+
* 2026-01-01..now since a fixed instant
|
|
23
|
+
*
|
|
24
|
+
* Everything here is pure and takes `now` as an argument: reading the clock
|
|
25
|
+
* during render would disagree between the server and the browser.
|
|
26
|
+
*/
|
|
27
|
+
type TimeUnit = "s" | "m" | "h" | "d" | "w" | "M" | "y";
|
|
28
|
+
interface ResolveOptions {
|
|
29
|
+
/** The instant `now` refers to. Required, so nothing here reads the clock. */
|
|
30
|
+
now: Date;
|
|
31
|
+
/**
|
|
32
|
+
* IANA zone the snapping happens in — "start of day" is a different instant
|
|
33
|
+
* in Auckland than in Denver. Defaults to the runtime zone.
|
|
34
|
+
*/
|
|
35
|
+
timeZone?: string;
|
|
36
|
+
}
|
|
37
|
+
interface ResolvedRange {
|
|
38
|
+
from: Date;
|
|
39
|
+
to: Date;
|
|
40
|
+
}
|
|
41
|
+
/** Why an expression could not be resolved. Shown to the user verbatim. */
|
|
42
|
+
declare class TimeExpressionError extends Error {
|
|
43
|
+
/** The part of the input at fault, for pointing at it. */
|
|
44
|
+
readonly input: string;
|
|
45
|
+
constructor(message: string,
|
|
46
|
+
/** The part of the input at fault, for pointing at it. */
|
|
47
|
+
input: string);
|
|
48
|
+
}
|
|
49
|
+
/** Resolves a single side of a range. Exported for the input's live feedback. */
|
|
50
|
+
declare function resolveExpression(expression: string, edge: "start" | "end", options: ResolveOptions): Date;
|
|
51
|
+
/**
|
|
52
|
+
* Turns a range expression into the two instants a query needs.
|
|
53
|
+
*
|
|
54
|
+
* Throws `TimeExpressionError` rather than returning a fallback range: a chart
|
|
55
|
+
* silently showing the wrong window is worse than one that says it cannot.
|
|
56
|
+
*/
|
|
57
|
+
declare function resolveTimeRange(expression: string, options: ResolveOptions): ResolvedRange;
|
|
58
|
+
/** Whether an expression resolves, without the caller writing a try/catch. */
|
|
59
|
+
declare function isValidTimeRange(expression: string, options: ResolveOptions): boolean;
|
|
60
|
+
interface TimeRangePreset {
|
|
61
|
+
expression: string;
|
|
62
|
+
label: string;
|
|
63
|
+
/** Groups presets under a heading in the picker. */
|
|
64
|
+
group: string;
|
|
65
|
+
}
|
|
66
|
+
/** Relative windows first, for debugging; calendar periods after, for reporting. */
|
|
67
|
+
declare const DEFAULT_PRESETS: TimeRangePreset[];
|
|
68
|
+
/**
|
|
69
|
+
* A sentence for the trigger.
|
|
70
|
+
*
|
|
71
|
+
* An unnamed relative range is described in its own terms — "Last 90 minutes" —
|
|
72
|
+
* because showing it as two timestamps throws away what the user chose. Only an
|
|
73
|
+
* absolute range, which has no relative meaning left, is shown as dates.
|
|
74
|
+
*/
|
|
75
|
+
declare function describeTimeRange(expression: string, options: ResolveOptions & {
|
|
76
|
+
locale?: string;
|
|
77
|
+
presets?: TimeRangePreset[];
|
|
78
|
+
}): string;
|
|
79
|
+
/** The resolved window, spelled out. Pairs with the relative label, not replaces it. */
|
|
80
|
+
declare function formatResolvedRange(range: ResolvedRange, options?: {
|
|
81
|
+
locale?: string;
|
|
82
|
+
timeZone?: string;
|
|
83
|
+
}): string;
|
|
84
|
+
/** Builds the expression for an absolute range chosen on a calendar. */
|
|
85
|
+
declare function absoluteExpression(from: Date, to: Date): string;
|
|
86
|
+
//#endregion
|
|
87
|
+
export { DEFAULT_PRESETS, ResolveOptions, ResolvedRange, TimeExpressionError, TimeRangePreset, TimeUnit, absoluteExpression, describeTimeRange, formatResolvedRange, isValidTimeRange, resolveExpression, resolveTimeRange };
|
|
88
|
+
//# sourceMappingURL=time-expression.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"time-expression.d.ts","names":[],"sources":["../../../src/components/time-range-picker/time-expression.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;KA0BY;UAEK;;EAEf,KAAK;;;;;EAKL;;UAGe;EACf,MAAM;EACN,IAAI;;;cAIO,4BAA4B;;WAI5B;EAFT,YAAA,iBAES;;EAAA;;;iBAwOG,kBACd,oBACA,uBACA,SAAS,iBACR;;;;;;;iBAwFa,iBAAiB,oBAAoB,SAAS,iBAAiB;;iBAoB/D,iBAAiB,oBAAoB,SAAS;UAa7C;EACf;EACA;;EAEA;;;cAIW,iBAAiB;;;;;;;;iBAyBd,kBACd,oBACA,SAAS;EAAmB;EAAiB,UAAU;;;iBA2BzC,oBACd,OAAO,eACP;EAAW;EAAiB;;;iBAWd,mBAAmB,MAAM,MAAM,IAAI"}
|