@dowel-ui/react 0.4.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.
Files changed (146) hide show
  1. package/README.md +9 -5
  2. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts +52 -0
  3. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts.map +1 -0
  4. package/dist/components/ai-extraction-review/ai-extraction-review.js +249 -0
  5. package/dist/components/ai-extraction-review/ai-extraction-review.js.map +1 -0
  6. package/dist/components/ai-extraction-review/extraction-model.d.ts +94 -0
  7. package/dist/components/ai-extraction-review/extraction-model.d.ts.map +1 -0
  8. package/dist/components/ai-extraction-review/extraction-model.js +91 -0
  9. package/dist/components/ai-extraction-review/extraction-model.js.map +1 -0
  10. package/dist/components/ai-extraction-review/index.d.ts +3 -0
  11. package/dist/components/ai-extraction-review/index.js +3 -0
  12. package/dist/components/ai-extraction-review/meta.js +17 -0
  13. package/dist/components/ai-extraction-review/meta.js.map +1 -0
  14. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts +72 -0
  15. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts.map +1 -0
  16. package/dist/components/ai-suggested-value/ai-suggested-value.js +126 -0
  17. package/dist/components/ai-suggested-value/ai-suggested-value.js.map +1 -0
  18. package/dist/components/ai-suggested-value/index.d.ts +2 -0
  19. package/dist/components/ai-suggested-value/index.js +2 -0
  20. package/dist/components/ai-suggested-value/meta.js +17 -0
  21. package/dist/components/ai-suggested-value/meta.js.map +1 -0
  22. package/dist/components/alert/alert.d.ts +1 -1
  23. package/dist/components/avatar/avatar.d.ts +1 -1
  24. package/dist/components/badge/badge.d.ts +1 -1
  25. package/dist/components/button/button.d.ts +1 -1
  26. package/dist/components/confirm-typed/confirm-typed.d.ts +47 -0
  27. package/dist/components/confirm-typed/confirm-typed.d.ts.map +1 -0
  28. package/dist/components/confirm-typed/confirm-typed.js +106 -0
  29. package/dist/components/confirm-typed/confirm-typed.js.map +1 -0
  30. package/dist/components/confirm-typed/index.d.ts +2 -0
  31. package/dist/components/confirm-typed/index.js +2 -0
  32. package/dist/components/confirm-typed/meta.js +17 -0
  33. package/dist/components/confirm-typed/meta.js.map +1 -0
  34. package/dist/components/cron-editor/cron-editor.d.ts +36 -0
  35. package/dist/components/cron-editor/cron-editor.d.ts.map +1 -0
  36. package/dist/components/cron-editor/cron-editor.js +493 -0
  37. package/dist/components/cron-editor/cron-editor.js.map +1 -0
  38. package/dist/components/cron-editor/cron-expression.d.ts +82 -0
  39. package/dist/components/cron-editor/cron-expression.d.ts.map +1 -0
  40. package/dist/components/cron-editor/cron-expression.js +352 -0
  41. package/dist/components/cron-editor/cron-expression.js.map +1 -0
  42. package/dist/components/cron-editor/index.d.ts +3 -0
  43. package/dist/components/cron-editor/index.js +3 -0
  44. package/dist/components/cron-editor/meta.js +17 -0
  45. package/dist/components/cron-editor/meta.js.map +1 -0
  46. package/dist/components/dns-record/dns-record.d.ts +61 -0
  47. package/dist/components/dns-record/dns-record.d.ts.map +1 -0
  48. package/dist/components/dns-record/dns-record.js +227 -0
  49. package/dist/components/dns-record/dns-record.js.map +1 -0
  50. package/dist/components/dns-record/index.d.ts +2 -0
  51. package/dist/components/dns-record/index.js +2 -0
  52. package/dist/components/dns-record/meta.js +17 -0
  53. package/dist/components/dns-record/meta.js.map +1 -0
  54. package/dist/components/permission-matrix/index.d.ts +3 -0
  55. package/dist/components/permission-matrix/index.js +3 -0
  56. package/dist/components/permission-matrix/meta.js +17 -0
  57. package/dist/components/permission-matrix/meta.js.map +1 -0
  58. package/dist/components/permission-matrix/permission-matrix.d.ts +46 -0
  59. package/dist/components/permission-matrix/permission-matrix.d.ts.map +1 -0
  60. package/dist/components/permission-matrix/permission-matrix.js +199 -0
  61. package/dist/components/permission-matrix/permission-matrix.js.map +1 -0
  62. package/dist/components/permission-matrix/permission-model.d.ts +53 -0
  63. package/dist/components/permission-matrix/permission-model.d.ts.map +1 -0
  64. package/dist/components/permission-matrix/permission-model.js +64 -0
  65. package/dist/components/permission-matrix/permission-model.js.map +1 -0
  66. package/dist/components/progress/progress.d.ts +1 -1
  67. package/dist/components/secret-field/index.d.ts +2 -0
  68. package/dist/components/secret-field/index.js +2 -0
  69. package/dist/components/secret-field/meta.js +17 -0
  70. package/dist/components/secret-field/meta.js.map +1 -0
  71. package/dist/components/secret-field/secret-field.d.ts +58 -0
  72. package/dist/components/secret-field/secret-field.d.ts.map +1 -0
  73. package/dist/components/secret-field/secret-field.js +168 -0
  74. package/dist/components/secret-field/secret-field.js.map +1 -0
  75. package/dist/components/session-expiry/index.d.ts +2 -0
  76. package/dist/components/session-expiry/index.js +2 -0
  77. package/dist/components/session-expiry/meta.js +17 -0
  78. package/dist/components/session-expiry/meta.js.map +1 -0
  79. package/dist/components/session-expiry/session-expiry.d.ts +64 -0
  80. package/dist/components/session-expiry/session-expiry.d.ts.map +1 -0
  81. package/dist/components/session-expiry/session-expiry.js +185 -0
  82. package/dist/components/session-expiry/session-expiry.js.map +1 -0
  83. package/dist/components/shortcut-recorder/index.d.ts +3 -0
  84. package/dist/components/shortcut-recorder/index.js +3 -0
  85. package/dist/components/shortcut-recorder/meta.js +17 -0
  86. package/dist/components/shortcut-recorder/meta.js.map +1 -0
  87. package/dist/components/shortcut-recorder/shortcut-model.d.ts +67 -0
  88. package/dist/components/shortcut-recorder/shortcut-model.d.ts.map +1 -0
  89. package/dist/components/shortcut-recorder/shortcut-model.js +168 -0
  90. package/dist/components/shortcut-recorder/shortcut-model.js.map +1 -0
  91. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts +47 -0
  92. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts.map +1 -0
  93. package/dist/components/shortcut-recorder/shortcut-recorder.js +212 -0
  94. package/dist/components/shortcut-recorder/shortcut-recorder.js.map +1 -0
  95. package/dist/components/spinner/spinner.d.ts +1 -1
  96. package/dist/components/sync-status/index.d.ts +2 -0
  97. package/dist/components/sync-status/index.js +2 -0
  98. package/dist/components/sync-status/meta.js +17 -0
  99. package/dist/components/sync-status/meta.js.map +1 -0
  100. package/dist/components/sync-status/sync-status.d.ts +65 -0
  101. package/dist/components/sync-status/sync-status.d.ts.map +1 -0
  102. package/dist/components/sync-status/sync-status.js +103 -0
  103. package/dist/components/sync-status/sync-status.js.map +1 -0
  104. package/dist/components/toast/toast.d.ts +1 -1
  105. package/dist/index.d.ts +25 -1
  106. package/dist/index.js +16 -2
  107. package/dist/registry/components.d.ts.map +1 -1
  108. package/dist/registry/components.js +73 -53
  109. package/dist/registry/components.js.map +1 -1
  110. package/package.json +4 -4
  111. package/src/components/ai-extraction-review/ai-extraction-review.tsx +492 -0
  112. package/src/components/ai-extraction-review/extraction-model.ts +159 -0
  113. package/src/components/ai-extraction-review/index.ts +13 -0
  114. package/src/components/ai-extraction-review/meta.ts +24 -0
  115. package/src/components/ai-suggested-value/ai-suggested-value.tsx +295 -0
  116. package/src/components/ai-suggested-value/index.ts +6 -0
  117. package/src/components/ai-suggested-value/meta.ts +24 -0
  118. package/src/components/confirm-typed/confirm-typed.tsx +191 -0
  119. package/src/components/confirm-typed/index.ts +1 -0
  120. package/src/components/confirm-typed/meta.ts +22 -0
  121. package/src/components/cron-editor/cron-editor.tsx +676 -0
  122. package/src/components/cron-editor/cron-expression.ts +552 -0
  123. package/src/components/cron-editor/index.ts +23 -0
  124. package/src/components/cron-editor/meta.ts +23 -0
  125. package/src/components/dns-record/dns-record.tsx +315 -0
  126. package/src/components/dns-record/index.ts +7 -0
  127. package/src/components/dns-record/meta.ts +21 -0
  128. package/src/components/permission-matrix/index.ts +14 -0
  129. package/src/components/permission-matrix/meta.ts +23 -0
  130. package/src/components/permission-matrix/permission-matrix.tsx +368 -0
  131. package/src/components/permission-matrix/permission-model.ts +120 -0
  132. package/src/components/secret-field/index.ts +6 -0
  133. package/src/components/secret-field/meta.ts +21 -0
  134. package/src/components/secret-field/secret-field.tsx +284 -0
  135. package/src/components/session-expiry/index.ts +9 -0
  136. package/src/components/session-expiry/meta.ts +23 -0
  137. package/src/components/session-expiry/session-expiry.tsx +321 -0
  138. package/src/components/shortcut-recorder/index.ts +19 -0
  139. package/src/components/shortcut-recorder/meta.ts +22 -0
  140. package/src/components/shortcut-recorder/shortcut-model.ts +204 -0
  141. package/src/components/shortcut-recorder/shortcut-recorder.tsx +336 -0
  142. package/src/components/sync-status/index.ts +9 -0
  143. package/src/components/sync-status/meta.ts +21 -0
  144. package/src/components/sync-status/sync-status.tsx +217 -0
  145. package/src/index.ts +10 -0
  146. package/src/registry/components.ts +20 -0
@@ -0,0 +1,676 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useId,
7
+ useMemo,
8
+ useState,
9
+ useSyncExternalStore,
10
+ type ComponentPropsWithRef,
11
+ type ReactNode,
12
+ } from "react";
13
+
14
+ import { Input } from "@/components/input";
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from "@/components/select";
22
+ import { focusRing } from "@/lib/styles";
23
+ import { cn } from "@/lib/utils";
24
+
25
+ import {
26
+ CronExpressionError,
27
+ describeCron,
28
+ nextRuns,
29
+ parseCron,
30
+ type CronSchedule,
31
+ } from "./cron-expression";
32
+
33
+ /**
34
+ * The "run this nightly" control.
35
+ *
36
+ * GitHub Actions, Vercel, Airflow, Sentry and every admin panel with a
37
+ * schedule draws one by hand: a few frequency controls that write a cron
38
+ * expression, the expression itself for anyone who can read it, the sentence
39
+ * beside it for everyone else, and the next few runs so a mistake is visible
40
+ * before it is saved. The packages that exist are bound to Ant Design or ship
41
+ * their own stylesheet — see `cron-expression.ts` for the value model, which
42
+ * is where the two mistakes every reimplementation makes actually live.
43
+ *
44
+ * Composed from the library's own Select and Input, and split into parts so
45
+ * an app can show the expression and its reading without the builder, or the
46
+ * next runs beside a saved schedule.
47
+ */
48
+
49
+ interface CronContextValue {
50
+ value: string;
51
+ setValue: (expression: string) => void;
52
+ schedule: CronSchedule | null;
53
+ timeZone?: string;
54
+ locale?: string;
55
+ /** Null until the clock is known, which on a server render is never. */
56
+ now: Date | null;
57
+ }
58
+
59
+ const CronContext = createContext<CronContextValue | null>(null);
60
+
61
+ function useCron(component: string): CronContextValue {
62
+ const context = useContext(CronContext);
63
+ if (!context) throw new Error(`${component} must be used inside <Cron>.`);
64
+ return context;
65
+ }
66
+
67
+ /* The clock, for callers who did not supply one — the same arrangement as
68
+ time-range-picker: a null server snapshot, so nothing clock-dependent is
69
+ rendered until the real instant arrives after hydration. */
70
+ const subscribeToNothing = () => () => undefined;
71
+ let sharedClock: Date | null = null;
72
+ const readSharedClock = () => (sharedClock ??= new Date());
73
+ const readNoClock = () => null;
74
+
75
+ export interface CronProps extends Omit<
76
+ ComponentPropsWithRef<"div">,
77
+ "defaultValue" | "onChange" | "children"
78
+ > {
79
+ /** The expression. Controlled. */
80
+ value?: string;
81
+ defaultValue?: string;
82
+ onValueChange?: (expression: string) => void;
83
+ /** IANA zone the expression's times are in. Defaults to the runtime zone. */
84
+ timeZone?: string;
85
+ locale?: string;
86
+ /** The instant next runs are counted from. Pass one that changes when you refresh. */
87
+ now?: Date;
88
+ children: ReactNode;
89
+ }
90
+
91
+ export function Cron({
92
+ className,
93
+ value: valueProp,
94
+ defaultValue = "0 9 * * 1",
95
+ onValueChange,
96
+ timeZone,
97
+ locale,
98
+ now,
99
+ children,
100
+ ...props
101
+ }: CronProps) {
102
+ const [uncontrolled, setUncontrolled] = useState(defaultValue);
103
+ const value = valueProp ?? uncontrolled;
104
+
105
+ const fallbackClock = useSyncExternalStore(subscribeToNothing, readSharedClock, readNoClock);
106
+ const clock = now ?? fallbackClock;
107
+
108
+ const schedule = useMemo(() => {
109
+ try {
110
+ return parseCron(value);
111
+ } catch {
112
+ return null;
113
+ }
114
+ }, [value]);
115
+
116
+ const context = useMemo<CronContextValue>(
117
+ () => ({
118
+ value,
119
+ setValue: (expression) => {
120
+ setUncontrolled(expression);
121
+ onValueChange?.(expression);
122
+ },
123
+ schedule,
124
+ timeZone,
125
+ locale,
126
+ now: clock,
127
+ }),
128
+ [value, schedule, timeZone, locale, clock, onValueChange],
129
+ );
130
+
131
+ return (
132
+ <CronContext.Provider value={context}>
133
+ <div data-slot="cron-editor" className={cn("flex flex-col gap-4", className)} {...props}>
134
+ {children}
135
+ </div>
136
+ </CronContext.Provider>
137
+ );
138
+ }
139
+
140
+ /* ------------------------------------------------------------------ */
141
+ /* Builder */
142
+ /* ------------------------------------------------------------------ */
143
+
144
+ type Frequency = "minute" | "hour" | "day" | "week" | "month" | "year" | "custom";
145
+
146
+ const FREQUENCIES: { value: Frequency; label: string }[] = [
147
+ { value: "minute", label: "Every minute" },
148
+ { value: "hour", label: "Every hour" },
149
+ { value: "day", label: "Every day" },
150
+ { value: "week", label: "Every week" },
151
+ { value: "month", label: "Every month" },
152
+ { value: "year", label: "Every year" },
153
+ { value: "custom", label: "Custom" },
154
+ ];
155
+
156
+ interface BuilderState {
157
+ frequency: Frequency;
158
+ minuteStep: number;
159
+ hourStep: number;
160
+ minute: number;
161
+ hour: number;
162
+ /** 0 = Sunday. */
163
+ days: number[];
164
+ dayOfMonth: number;
165
+ month: number;
166
+ }
167
+
168
+ const isNumber = (token: string) => /^\d+$/.test(token);
169
+ const isStar = (token: string) => token === "*";
170
+ const stepOf = (token: string) => (/^\*\/\d+$/.test(token) ? Number(token.slice(2)) : null);
171
+
172
+ /**
173
+ * Which builder shape an expression fits, if any. Anything the controls
174
+ * cannot express — every 15 minutes during office hours, say — is "custom",
175
+ * and the expression field becomes the editor rather than the builder quietly
176
+ * showing something else.
177
+ */
178
+ function classify(schedule: CronSchedule | null): BuilderState {
179
+ const state: BuilderState = {
180
+ frequency: "custom",
181
+ minuteStep: 1,
182
+ hourStep: 1,
183
+ minute: 0,
184
+ hour: 9,
185
+ days: [1],
186
+ dayOfMonth: 1,
187
+ month: 1,
188
+ };
189
+ if (!schedule) return state;
190
+
191
+ const [m = "", h = "", dom = "", mon = "", dow = ""] = schedule.expression.split(" ");
192
+ if (isNumber(m)) state.minute = schedule.minutes[0] ?? 0;
193
+ if (isNumber(h)) state.hour = schedule.hours[0] ?? 0;
194
+
195
+ const restStar = isStar(dom) && isStar(mon) && isStar(dow);
196
+ const timeFixed = isNumber(m) && isNumber(h);
197
+
198
+ if ((isStar(m) || stepOf(m) !== null) && isStar(h) && restStar) {
199
+ state.frequency = "minute";
200
+ state.minuteStep = stepOf(m) ?? 1;
201
+ } else if (isNumber(m) && (isStar(h) || stepOf(h) !== null) && restStar) {
202
+ state.frequency = "hour";
203
+ state.hourStep = stepOf(h) ?? 1;
204
+ } else if (timeFixed && restStar) {
205
+ state.frequency = "day";
206
+ } else if (timeFixed && isStar(dom) && isStar(mon) && !dow.includes("/")) {
207
+ state.frequency = "week";
208
+ state.days = schedule.daysOfWeek;
209
+ } else if (timeFixed && isNumber(dom) && isStar(mon) && isStar(dow)) {
210
+ state.frequency = "month";
211
+ state.dayOfMonth = schedule.daysOfMonth[0] ?? 1;
212
+ } else if (
213
+ timeFixed &&
214
+ isNumber(dom) &&
215
+ schedule.months.length === 1 &&
216
+ !/[-,/]/.test(mon) &&
217
+ isStar(dow)
218
+ ) {
219
+ state.frequency = "year";
220
+ state.dayOfMonth = schedule.daysOfMonth[0] ?? 1;
221
+ state.month = schedule.months[0] ?? 1;
222
+ }
223
+ return state;
224
+ }
225
+
226
+ /** `1,2,3,4,5` written the way people write it: `1-5`. Runs of two stay a list. */
227
+ function compressDays(days: number[]): string {
228
+ const sorted = [...new Set(days)].sort((a, b) => a - b);
229
+ const parts: string[] = [];
230
+ for (let i = 0; i < sorted.length;) {
231
+ let j = i;
232
+ while (j + 1 < sorted.length && sorted[j + 1] === (sorted[j] ?? 0) + 1) j += 1;
233
+ const first = String(sorted[i] ?? 0);
234
+ const last = String(sorted[j] ?? 0);
235
+ parts.push(j - i >= 2 ? `${first}-${last}` : j > i ? `${first},${last}` : first);
236
+ i = j + 1;
237
+ }
238
+ return parts.join(",");
239
+ }
240
+
241
+ function build(state: BuilderState, current: string): string {
242
+ const { minute, hour, dayOfMonth, month } = state;
243
+ switch (state.frequency) {
244
+ case "minute":
245
+ return `${state.minuteStep > 1 ? `*/${String(state.minuteStep)}` : "*"} * * * *`;
246
+ case "hour":
247
+ return `${String(minute)} ${state.hourStep > 1 ? `*/${String(state.hourStep)}` : "*"} * * *`;
248
+ case "day":
249
+ return `${String(minute)} ${String(hour)} * * *`;
250
+ case "week":
251
+ return `${String(minute)} ${String(hour)} * * ${compressDays(state.days)}`;
252
+ case "month":
253
+ return `${String(minute)} ${String(hour)} ${String(dayOfMonth)} * *`;
254
+ case "year":
255
+ return `${String(minute)} ${String(hour)} ${String(dayOfMonth)} ${String(month)} *`;
256
+ case "custom":
257
+ return current;
258
+ }
259
+ }
260
+
261
+ /** Monday first, Sunday last. */
262
+ const WEEK = [1, 2, 3, 4, 5, 6, 0];
263
+ // 4 January 2026 is a Sunday; 1 January 2026 is in month 1.
264
+ const weekdayName = (day: number, locale: string | undefined, style: "long" | "short") =>
265
+ new Intl.DateTimeFormat(locale, { weekday: style }).format(new Date(2026, 0, 4 + day));
266
+ const monthName = (month: number, locale: string | undefined) =>
267
+ new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2026, month - 1, 1));
268
+
269
+ export type CronBuilderProps = Omit<ComponentPropsWithRef<"div">, "children">;
270
+
271
+ export function CronBuilder({ className, ...props }: CronBuilderProps) {
272
+ const { value, setValue, schedule, locale } = useCron("CronBuilder");
273
+ const uid = useId();
274
+ const state = useMemo(() => classify(schedule), [schedule]);
275
+
276
+ const update = (patch: Partial<BuilderState>) => {
277
+ setValue(build({ ...state, ...patch }, value));
278
+ };
279
+
280
+ const numberField = (
281
+ key: "minuteStep" | "hourStep" | "minute" | "dayOfMonth",
282
+ label: string,
283
+ min: number,
284
+ max: number,
285
+ describedBy?: string,
286
+ ) => (
287
+ <NumberField
288
+ id={`${uid}-${key}`}
289
+ label={label}
290
+ min={min}
291
+ max={max}
292
+ value={state[key]}
293
+ describedBy={describedBy}
294
+ onCommit={(next) => {
295
+ update({ [key]: next });
296
+ }}
297
+ />
298
+ );
299
+
300
+ const hasTime = ["day", "week", "month", "year"].includes(state.frequency);
301
+ const hasDayOfMonth = state.frequency === "month" || state.frequency === "year";
302
+ const dayHintId = `${uid}-day-hint`;
303
+ const daysHintId = `${uid}-days-hint`;
304
+
305
+ return (
306
+ <div
307
+ data-slot="cron-builder"
308
+ data-frequency={state.frequency}
309
+ className={cn("flex flex-wrap items-end gap-3", className)}
310
+ {...props}
311
+ >
312
+ <div className="flex flex-col gap-1.5">
313
+ <label htmlFor={`${uid}-frequency`} className="text-xs font-medium">
314
+ Frequency
315
+ </label>
316
+ <Select
317
+ value={state.frequency}
318
+ onValueChange={(frequency) => {
319
+ update({ frequency: frequency as Frequency });
320
+ }}
321
+ >
322
+ <SelectTrigger id={`${uid}-frequency`} className="w-40">
323
+ <SelectValue />
324
+ </SelectTrigger>
325
+ <SelectContent>
326
+ {FREQUENCIES.map((option) => (
327
+ <SelectItem key={option.value} value={option.value}>
328
+ {option.label}
329
+ </SelectItem>
330
+ ))}
331
+ </SelectContent>
332
+ </Select>
333
+ </div>
334
+
335
+ {state.frequency === "minute"
336
+ ? numberField("minuteStep", "Every how many minutes", 1, 59)
337
+ : null}
338
+
339
+ {state.frequency === "hour" ? (
340
+ <>
341
+ {numberField("hourStep", "Every how many hours", 1, 23)}
342
+ {numberField("minute", "At minute", 0, 59)}
343
+ </>
344
+ ) : null}
345
+
346
+ {hasTime ? (
347
+ <div className="flex flex-col gap-1.5">
348
+ <label htmlFor={`${uid}-time`} className="text-xs font-medium">
349
+ At
350
+ </label>
351
+ <Input
352
+ id={`${uid}-time`}
353
+ type="time"
354
+ inputSize="sm"
355
+ className="w-32"
356
+ value={`${String(state.hour).padStart(2, "0")}:${String(state.minute).padStart(2, "0")}`}
357
+ onChange={(event) => {
358
+ const [hour, minute] = event.target.value.split(":").map(Number);
359
+ if (
360
+ hour === undefined ||
361
+ minute === undefined ||
362
+ Number.isNaN(hour) ||
363
+ Number.isNaN(minute)
364
+ ) {
365
+ return;
366
+ }
367
+ update({ hour, minute });
368
+ }}
369
+ />
370
+ </div>
371
+ ) : null}
372
+
373
+ {state.frequency === "week" ? (
374
+ <div className="flex flex-col gap-1.5">
375
+ <span id={`${uid}-days-label`} className="text-xs font-medium">
376
+ On
377
+ </span>
378
+ <div
379
+ role="group"
380
+ aria-labelledby={`${uid}-days-label`}
381
+ aria-describedby={daysHintId}
382
+ className="flex gap-1"
383
+ >
384
+ {WEEK.map((day) => {
385
+ const pressed = state.days.includes(day);
386
+ const last = pressed && state.days.length === 1;
387
+ return (
388
+ <button
389
+ key={day}
390
+ type="button"
391
+ aria-pressed={pressed}
392
+ aria-label={weekdayName(day, locale, "long")}
393
+ className={cn(
394
+ "h-8 min-w-9 rounded-md border px-2 text-xs font-medium transition-colors",
395
+ pressed
396
+ ? "border-primary bg-primary text-primary-foreground"
397
+ : "border-input bg-background hover:bg-accent hover:text-accent-foreground",
398
+ focusRing,
399
+ )}
400
+ onClick={() => {
401
+ // The last day stays: a week schedule with no day is not a
402
+ // schedule. The group's description says so, so the refusal
403
+ // is not silent.
404
+ if (last) return;
405
+ update({
406
+ days: pressed
407
+ ? state.days.filter((d) => d !== day)
408
+ : [...state.days, day],
409
+ });
410
+ }}
411
+ >
412
+ {weekdayName(day, locale, "short")}
413
+ </button>
414
+ );
415
+ })}
416
+ </div>
417
+ <p id={daysHintId} className="text-xs text-muted-foreground">
418
+ Pick at least one day.
419
+ </p>
420
+ </div>
421
+ ) : null}
422
+
423
+ {hasDayOfMonth ? (
424
+ <div className="flex flex-col gap-1.5">
425
+ {numberField(
426
+ "dayOfMonth",
427
+ "On day",
428
+ 1,
429
+ 31,
430
+ state.dayOfMonth > 28 ? dayHintId : undefined,
431
+ )}
432
+ {state.dayOfMonth > 28 ? (
433
+ <p id={dayHintId} data-slot="cron-day-hint" className="text-xs text-warning">
434
+ Months with fewer days skip this run.
435
+ </p>
436
+ ) : null}
437
+ </div>
438
+ ) : null}
439
+
440
+ {state.frequency === "year" ? (
441
+ <div className="flex flex-col gap-1.5">
442
+ <label htmlFor={`${uid}-month`} className="text-xs font-medium">
443
+ In
444
+ </label>
445
+ <Select
446
+ value={String(state.month)}
447
+ onValueChange={(month) => {
448
+ update({ month: Number(month) });
449
+ }}
450
+ >
451
+ <SelectTrigger id={`${uid}-month`} className="w-36">
452
+ <SelectValue />
453
+ </SelectTrigger>
454
+ <SelectContent>
455
+ {Array.from({ length: 12 }, (_, index) => index + 1).map((month) => (
456
+ <SelectItem key={month} value={String(month)}>
457
+ {monthName(month, locale)}
458
+ </SelectItem>
459
+ ))}
460
+ </SelectContent>
461
+ </Select>
462
+ </div>
463
+ ) : null}
464
+
465
+ {state.frequency === "custom" ? (
466
+ <p className="text-xs text-muted-foreground">
467
+ This schedule needs the expression. Edit it below.
468
+ </p>
469
+ ) : null}
470
+ </div>
471
+ );
472
+ }
473
+
474
+ /**
475
+ * A number the builder owns, with the text the reader is typing kept apart
476
+ * from the value — so clearing the field to type a new number does not snap
477
+ * it back to the old one after the first keystroke.
478
+ */
479
+ function NumberField({
480
+ id,
481
+ label,
482
+ min,
483
+ max,
484
+ value,
485
+ describedBy,
486
+ onCommit,
487
+ }: {
488
+ id: string;
489
+ label: string;
490
+ min: number;
491
+ max: number;
492
+ value: number;
493
+ describedBy?: string;
494
+ onCommit: (value: number) => void;
495
+ }) {
496
+ const [draft, setDraft] = useState(String(value));
497
+ const [seen, setSeen] = useState(value);
498
+ if (seen !== value) {
499
+ setSeen(value);
500
+ setDraft(String(value));
501
+ }
502
+
503
+ return (
504
+ <div className="flex flex-col gap-1.5">
505
+ <label htmlFor={id} className="text-xs font-medium">
506
+ {label}
507
+ </label>
508
+ <Input
509
+ id={id}
510
+ type="number"
511
+ inputSize="sm"
512
+ min={min}
513
+ max={max}
514
+ value={draft}
515
+ aria-describedby={describedBy}
516
+ className="w-24"
517
+ onChange={(event) => {
518
+ const text = event.target.value;
519
+ setDraft(text);
520
+ const next = Number(text);
521
+ if (text === "" || !Number.isInteger(next) || next < min || next > max) return;
522
+ if (next !== value) onCommit(next);
523
+ }}
524
+ onBlur={() => {
525
+ // Whatever was left half-typed goes back to the value in force.
526
+ if (draft !== String(value)) setDraft(String(value));
527
+ }}
528
+ />
529
+ </div>
530
+ );
531
+ }
532
+
533
+ /* ------------------------------------------------------------------ */
534
+ /* Expression */
535
+ /* ------------------------------------------------------------------ */
536
+
537
+ export type CronExpressionProps = Omit<ComponentPropsWithRef<"div">, "children">;
538
+
539
+ /**
540
+ * The expression, editable, with its reading underneath — or the reason it is
541
+ * invalid. An invalid entry is never applied: the last valid schedule stands
542
+ * until the field says something that parses.
543
+ */
544
+ export function CronExpression({ className, ...props }: CronExpressionProps) {
545
+ const { value, setValue, locale } = useCron("CronExpression");
546
+ const id = useId();
547
+
548
+ const [draft, setDraft] = useState(value);
549
+ // The value can change under the field — the builder, or the consumer.
550
+ const [lastValue, setLastValue] = useState(value);
551
+ if (value !== lastValue) {
552
+ setLastValue(value);
553
+ setDraft(value);
554
+ }
555
+
556
+ const feedback = useMemo(() => {
557
+ try {
558
+ return { text: describeCron(draft, { locale }), error: null as string | null };
559
+ } catch (thrown) {
560
+ return {
561
+ text: null,
562
+ error: thrown instanceof CronExpressionError ? thrown.message : "Invalid schedule.",
563
+ };
564
+ }
565
+ }, [draft, locale]);
566
+
567
+ return (
568
+ <div
569
+ data-slot="cron-expression"
570
+ className={cn("flex flex-col gap-1.5", className)}
571
+ {...props}
572
+ >
573
+ <label htmlFor={`${id}-input`} className="text-xs font-medium">
574
+ Expression
575
+ </label>
576
+ <Input
577
+ id={`${id}-input`}
578
+ inputSize="sm"
579
+ value={draft}
580
+ spellCheck={false}
581
+ autoComplete="off"
582
+ aria-invalid={feedback.error ? true : undefined}
583
+ aria-describedby={`${id}-feedback`}
584
+ className="font-mono"
585
+ onChange={(event) => {
586
+ const next = event.target.value;
587
+ setDraft(next);
588
+ try {
589
+ parseCron(next);
590
+ setValue(next.trim());
591
+ } catch {
592
+ // Said in the feedback; the last valid schedule stands.
593
+ }
594
+ }}
595
+ />
596
+ {/* One element for both, so a reader hears the error replace the
597
+ reading rather than both at once. */}
598
+ <p
599
+ id={`${id}-feedback`}
600
+ data-slot="cron-feedback"
601
+ className={cn("text-xs", feedback.error ? "text-destructive" : "text-muted-foreground")}
602
+ >
603
+ {feedback.error ?? feedback.text}
604
+ </p>
605
+ </div>
606
+ );
607
+ }
608
+
609
+ /* ------------------------------------------------------------------ */
610
+ /* Next runs */
611
+ /* ------------------------------------------------------------------ */
612
+
613
+ export interface CronNextRunsProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
614
+ count?: number;
615
+ }
616
+
617
+ /**
618
+ * When it will fire, in the zone it is defined in. The zone is in the
619
+ * heading because a time with no zone is the classic scheduling mistake, and
620
+ * a schedule that never runs says so rather than showing an empty list.
621
+ */
622
+ export function CronNextRuns({ className, count = 5, ...props }: CronNextRunsProps) {
623
+ const { value, schedule, timeZone, locale, now } = useCron("CronNextRuns");
624
+ const headingId = useId();
625
+
626
+ const runs = useMemo(
627
+ () => (now && schedule ? nextRuns(value, { from: now, count, timeZone }) : null),
628
+ [value, schedule, now, count, timeZone],
629
+ );
630
+
631
+ // Nothing clock-dependent before the clock is known.
632
+ if (!runs) return null;
633
+
634
+ const format = new Intl.DateTimeFormat(locale, {
635
+ dateStyle: "medium",
636
+ timeStyle: "short",
637
+ timeZone,
638
+ });
639
+
640
+ return (
641
+ <div
642
+ data-slot="cron-next-runs"
643
+ className={cn("flex flex-col gap-1.5", className)}
644
+ {...props}
645
+ >
646
+ <p id={headingId} className="text-xs font-medium">
647
+ Next {count === 1 ? "run" : `${String(count)} runs`}
648
+ <span className="font-normal text-muted-foreground">
649
+ {" · "}
650
+ {timeZone ?? "local time"}
651
+ </span>
652
+ </p>
653
+ {runs.length === 0 ? (
654
+ <p data-slot="cron-never" className="text-xs text-destructive">
655
+ Never runs: no day matches this schedule.
656
+ </p>
657
+ ) : (
658
+ <ol
659
+ aria-labelledby={headingId}
660
+ className="m-0 flex list-none flex-col gap-0.5 p-0 text-sm"
661
+ >
662
+ {runs.map((run) => (
663
+ <li key={run.toISOString()}>
664
+ <time dateTime={run.toISOString()} className="tabular-nums">
665
+ {format.format(run)}
666
+ </time>
667
+ </li>
668
+ ))}
669
+ {runs.length < count ? (
670
+ <li className="text-xs text-muted-foreground">Then nothing within five years.</li>
671
+ ) : null}
672
+ </ol>
673
+ )}
674
+ </div>
675
+ );
676
+ }