@marianmeres/stuic 3.45.3 → 3.47.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.
@@ -0,0 +1,517 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type { ValidateOptions } from "../../actions/validate.svelte.js";
4
+ import type { THC } from "../Thc/Thc.svelte";
5
+
6
+ type SnippetWithId = Snippet<[{ id: string }]>;
7
+
8
+ export interface CronPreset {
9
+ label: string;
10
+ value: string;
11
+ }
12
+
13
+ export type CronInputMode = "predefined" | "manual";
14
+
15
+ export interface Props {
16
+ // Core
17
+ value?: string;
18
+ el?: HTMLElement;
19
+ id?: string;
20
+
21
+ // Mode toggle (overrides show* flags when defined)
22
+ mode?: CronInputMode;
23
+
24
+ // InputWrap standard props
25
+ label?: SnippetWithId | THC;
26
+ description?: SnippetWithId | THC;
27
+ renderSize?: "sm" | "md" | "lg" | string;
28
+ required?: boolean;
29
+ disabled?: boolean;
30
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
31
+ class?: string;
32
+ style?: string;
33
+
34
+ // InputWrap layout
35
+ labelLeft?: boolean;
36
+ labelLeftWidth?: "normal" | "wide";
37
+ labelLeftBreakpoint?: number;
38
+ labelAfter?: SnippetWithId | THC;
39
+ below?: SnippetWithId | THC;
40
+
41
+ // CronInput-specific
42
+ showPresets?: boolean;
43
+ showFields?: boolean;
44
+ showRawInput?: boolean;
45
+ showDescription?: boolean;
46
+ showNextRun?: boolean;
47
+ presets?: CronPreset[];
48
+ onchange?: (expression: string, valid: boolean) => void;
49
+
50
+ // Sub-element class overrides
51
+ classLabel?: string;
52
+ classLabelBox?: string;
53
+ classInputBox?: string;
54
+ classInputBoxWrap?: string;
55
+ classInputBoxWrapInvalid?: string;
56
+ classDescBox?: string;
57
+ classBelowBox?: string;
58
+ classFields?: string;
59
+ classField?: string;
60
+ classFieldLabel?: string;
61
+ classFieldInput?: string;
62
+ classPreset?: string;
63
+ classRaw?: string;
64
+ classSummary?: string;
65
+ classToggleButton?: string;
66
+ }
67
+
68
+ export const DEFAULT_PRESETS: CronPreset[] = [
69
+ { label: "Every minute", value: "* * * * *" },
70
+ { label: "Every 5 minutes", value: "*/5 * * * *" },
71
+ { label: "Every 15 minutes", value: "*/15 * * * *" },
72
+ { label: "Every 30 minutes", value: "*/30 * * * *" },
73
+ { label: "Every hour", value: "0 * * * *" },
74
+ { label: "Every 6 hours", value: "0 */6 * * *" },
75
+ { label: "Daily at midnight", value: "0 0 * * *" },
76
+ { label: "Daily at noon", value: "0 12 * * *" },
77
+ { label: "Weekdays at 9:00", value: "0 9 * * 1-5" },
78
+ { label: "Weekly (Sunday midnight)", value: "0 0 * * 0" },
79
+ { label: "Monthly (1st at midnight)", value: "0 0 1 * *" },
80
+ ];
81
+
82
+ const FIELD_DEFS = [
83
+ { key: "minute", label: "Min", placeholder: "0-59" },
84
+ { key: "hour", label: "Hour", placeholder: "0-23" },
85
+ { key: "dayOfMonth", label: "Day", placeholder: "1-31" },
86
+ { key: "month", label: "Month", placeholder: "1-12" },
87
+ { key: "dayOfWeek", label: "Wday", placeholder: "0-6" },
88
+ ] as const;
89
+
90
+ const MONTH_NAMES = [
91
+ "", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
92
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
93
+ ];
94
+
95
+ const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
96
+
97
+ type FieldKey = (typeof FIELD_DEFS)[number]["key"];
98
+
99
+ function cronToHuman(expression: string): string {
100
+ const parts = expression.trim().split(/\s+/);
101
+ if (parts.length !== 5) return "";
102
+
103
+ const [minute, hour, dom, month, dow] = parts;
104
+ const segments: string[] = [];
105
+
106
+ // Minute
107
+ if (minute === "*") {
108
+ segments.push("every minute");
109
+ } else if (minute.startsWith("*/")) {
110
+ segments.push(`every ${minute.slice(2)} minutes`);
111
+ } else {
112
+ segments.push(`at minute ${minute}`);
113
+ }
114
+
115
+ // Hour
116
+ if (hour === "*") {
117
+ // implied by "every minute/N minutes"
118
+ } else if (hour.startsWith("*/")) {
119
+ segments.push(`every ${hour.slice(2)} hours`);
120
+ } else {
121
+ segments.push(`at ${hour.padStart(2, "0")}:${minute === "*" ? "00" : minute.padStart(2, "0")}`);
122
+ // remove the minute segment if we have a specific hour
123
+ if (minute !== "*" && !minute.includes("/")) {
124
+ segments.length = 0;
125
+ segments.push(`at ${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`);
126
+ }
127
+ }
128
+
129
+ // Day of month
130
+ if (dom !== "*") {
131
+ if (dom.includes("-")) {
132
+ segments.push(`on days ${dom} of the month`);
133
+ } else if (dom.includes(",")) {
134
+ segments.push(`on days ${dom} of the month`);
135
+ } else {
136
+ const n = parseInt(dom);
137
+ const suffix = n === 1 ? "st" : n === 2 ? "nd" : n === 3 ? "rd" : "th";
138
+ segments.push(`on the ${n}${suffix}`);
139
+ }
140
+ }
141
+
142
+ // Month
143
+ if (month !== "*") {
144
+ if (month.includes("-")) {
145
+ const [s, e] = month.split("-").map(Number);
146
+ segments.push(`in ${MONTH_NAMES[s]}-${MONTH_NAMES[e]}`);
147
+ } else if (month.includes(",")) {
148
+ const names = month.split(",").map((m) => MONTH_NAMES[parseInt(m)] || m);
149
+ segments.push(`in ${names.join(", ")}`);
150
+ } else {
151
+ segments.push(`in ${MONTH_NAMES[parseInt(month)] || month}`);
152
+ }
153
+ }
154
+
155
+ // Day of week
156
+ if (dow !== "*") {
157
+ if (dow.includes("-")) {
158
+ const [s, e] = dow.split("-").map(Number);
159
+ segments.push(`on ${DAY_NAMES[s]}-${DAY_NAMES[e]}`);
160
+ } else if (dow.includes(",")) {
161
+ const names = dow.split(",").map((d) => DAY_NAMES[parseInt(d)] || d);
162
+ segments.push(`on ${names.join(", ")}`);
163
+ } else {
164
+ segments.push(`on ${DAY_NAMES[parseInt(dow)] || dow}`);
165
+ }
166
+ }
167
+
168
+ return segments.length ? segments[0].charAt(0).toUpperCase() + segments.join(", ").slice(1) : "";
169
+ }
170
+ </script>
171
+
172
+ <script lang="ts">
173
+ import { CronParser } from "@marianmeres/cron";
174
+ import {
175
+ validate as validateAction,
176
+ type ValidationResult,
177
+ } from "../../actions/validate.svelte.js";
178
+ import { tooltip } from "../../actions/index.js";
179
+ import { iconList, iconSlidersHorizontal } from "../../icons/index.js";
180
+ import { getId } from "../../utils/get-id.js";
181
+ import { twMerge } from "../../utils/tw-merge.js";
182
+ import InputWrap from "../Input/_internal/InputWrap.svelte";
183
+
184
+ let {
185
+ value = $bindable("* * * * *"),
186
+ el = $bindable(),
187
+ id = getId(),
188
+ //
189
+ mode = $bindable<CronInputMode | undefined>("predefined"),
190
+ //
191
+ label,
192
+ description,
193
+ renderSize = "md",
194
+ required = false,
195
+ disabled = false,
196
+ validate,
197
+ class: classProp,
198
+ style,
199
+ //
200
+ labelLeft = false,
201
+ labelLeftWidth = "normal",
202
+ labelLeftBreakpoint = 480,
203
+ labelAfter,
204
+ below,
205
+ //
206
+ showPresets = true,
207
+ showFields = true,
208
+ showRawInput = true,
209
+ showDescription = true,
210
+ showNextRun = true,
211
+ presets,
212
+ onchange,
213
+ //
214
+ classLabel,
215
+ classLabelBox,
216
+ classInputBox,
217
+ classInputBoxWrap,
218
+ classInputBoxWrapInvalid,
219
+ classDescBox,
220
+ classBelowBox,
221
+ classFields,
222
+ classField,
223
+ classFieldLabel,
224
+ classFieldInput,
225
+ classPreset,
226
+ classRaw,
227
+ classSummary,
228
+ classToggleButton,
229
+ }: Props = $props();
230
+
231
+ // Mode toggle
232
+ const hasModeToggle = $derived(mode !== undefined);
233
+
234
+ function toggleMode() {
235
+ if (mode === "predefined") mode = "manual";
236
+ else mode = "predefined";
237
+ }
238
+
239
+ // Effective show flags — mode overrides explicit props when defined
240
+ let _showPresets = $derived(
241
+ hasModeToggle ? mode === "predefined" : showPresets
242
+ );
243
+ let _showFields = $derived(
244
+ hasModeToggle ? mode === "manual" : showFields
245
+ );
246
+ let _showRawInput = $derived(
247
+ hasModeToggle ? false : showRawInput
248
+ );
249
+ let _showDescription = $derived(
250
+ hasModeToggle ? mode === "manual" : showDescription
251
+ );
252
+ let _showNextRun = $derived(
253
+ hasModeToggle ? mode === "manual" : showNextRun
254
+ );
255
+
256
+ const BTN_CLS = [
257
+ "toggle-btn",
258
+ "px-2 rounded-r block",
259
+ "min-w-[44px] min-h-[44px]",
260
+ "flex items-center justify-center",
261
+ ].join(" ");
262
+
263
+ // Internal field state
264
+ let fields = $state<Record<FieldKey, string>>({
265
+ minute: "*",
266
+ hour: "*",
267
+ dayOfMonth: "*",
268
+ month: "*",
269
+ dayOfWeek: "*",
270
+ });
271
+
272
+ let rawValue = $state("* * * * *");
273
+ let selectedPreset = $state("");
274
+ let error = $state("");
275
+
276
+ // Validation integration
277
+ let validation: ValidationResult | undefined = $state();
278
+ const setValidationResult = (res: ValidationResult) => (validation = res);
279
+
280
+ // Track sync source to avoid loops
281
+ let _syncSource: "value" | "fields" | "raw" | "preset" | "" = "";
282
+
283
+ const _presets = $derived(presets ?? DEFAULT_PRESETS);
284
+
285
+ // Combine fields into expression
286
+ function fieldsToExpression(): string {
287
+ return FIELD_DEFS.map((d) => fields[d.key] || "*").join(" ");
288
+ }
289
+
290
+ // Split expression into fields
291
+ function expressionToFields(expr: string) {
292
+ const parts = expr.trim().split(/\s+/);
293
+ if (parts.length === 5) {
294
+ FIELD_DEFS.forEach((d, i) => {
295
+ fields[d.key] = parts[i];
296
+ });
297
+ }
298
+ }
299
+
300
+ // Match current expression to a preset
301
+ function matchPreset(expr: string): string {
302
+ const normalized = expr.trim().replace(/\s+/g, " ");
303
+ return _presets.find((p) => p.value === normalized)?.value ?? "";
304
+ }
305
+
306
+ // Validate expression using CronParser
307
+ function validateExpression(expr: string): boolean {
308
+ try {
309
+ new CronParser(expr);
310
+ error = "";
311
+ validation = undefined;
312
+ return true;
313
+ } catch (e: any) {
314
+ error = e.message || "Invalid cron expression";
315
+ validation = { valid: false, message: error };
316
+ return false;
317
+ }
318
+ }
319
+
320
+ // Sync external value → internal state
321
+ $effect(() => {
322
+ const v = value;
323
+ if (_syncSource === "fields" || _syncSource === "raw" || _syncSource === "preset") {
324
+ return;
325
+ }
326
+ _syncSource = "value";
327
+ expressionToFields(v);
328
+ rawValue = v;
329
+ selectedPreset = matchPreset(v);
330
+ validateExpression(v);
331
+ _syncSource = "";
332
+ });
333
+
334
+ // Handle field input changes
335
+ function onFieldInput() {
336
+ _syncSource = "fields";
337
+ const expr = fieldsToExpression();
338
+ rawValue = expr;
339
+ selectedPreset = matchPreset(expr);
340
+ const valid = validateExpression(expr);
341
+ if (valid) {
342
+ value = expr;
343
+ }
344
+ onchange?.(expr, valid);
345
+ _syncSource = "";
346
+ }
347
+
348
+ // Handle raw input changes
349
+ function onRawInput() {
350
+ _syncSource = "raw";
351
+ const expr = rawValue;
352
+ expressionToFields(expr);
353
+ selectedPreset = matchPreset(expr);
354
+ const valid = validateExpression(expr);
355
+ if (valid) {
356
+ value = expr;
357
+ }
358
+ onchange?.(expr, valid);
359
+ _syncSource = "";
360
+ }
361
+
362
+ // Handle preset selection
363
+ function onPresetChange() {
364
+ if (!selectedPreset) return;
365
+ _syncSource = "preset";
366
+ const expr = selectedPreset;
367
+ expressionToFields(expr);
368
+ rawValue = expr;
369
+ const valid = validateExpression(expr);
370
+ if (valid) {
371
+ value = expr;
372
+ }
373
+ onchange?.(expr, valid);
374
+ _syncSource = "";
375
+ }
376
+
377
+ // When only presets are visible, render as a plain select (no extra padding/border)
378
+ let presetsOnly = $derived(_showPresets && !_showFields && !_showRawInput && !_showDescription && !_showNextRun);
379
+
380
+ // Minute tick — triggers re-evaluation of "Next: ..." every 60s
381
+ let _tick = $state(0);
382
+ $effect(() => {
383
+ if (!_showNextRun) return;
384
+ const id = setInterval(() => _tick++, 60_000);
385
+ return () => clearInterval(id);
386
+ });
387
+
388
+ // Human-readable description
389
+ let humanDescription = $derived.by(() => {
390
+ if (!_showDescription && !_showNextRun) return "";
391
+ const desc = _showDescription ? cronToHuman(rawValue) : "";
392
+ if (!_showNextRun || error) return desc;
393
+ // read _tick to create reactive dependency
394
+ void _tick;
395
+ try {
396
+ const parser = new CronParser(rawValue);
397
+ const next = parser.getNextRun();
398
+ const fmt = next.toLocaleString("sv-SE", {
399
+ year: "numeric",
400
+ month: "2-digit",
401
+ day: "2-digit",
402
+ hour: "2-digit",
403
+ minute: "2-digit",
404
+ });
405
+ return desc ? `${desc}. Next: ${fmt}` : `Next: ${fmt}`;
406
+ } catch {
407
+ return desc;
408
+ }
409
+ });
410
+ </script>
411
+
412
+ <div bind:this={el}>
413
+ <InputWrap
414
+ {description}
415
+ class={classProp}
416
+ size={renderSize}
417
+ {id}
418
+ {label}
419
+ {labelAfter}
420
+ {below}
421
+ {required}
422
+ {disabled}
423
+ {labelLeft}
424
+ {labelLeftWidth}
425
+ {labelLeftBreakpoint}
426
+ {classLabel}
427
+ {classLabelBox}
428
+ {classInputBox}
429
+ {classInputBoxWrap}
430
+ classInputBoxWrapInvalid={twMerge(classInputBoxWrapInvalid)}
431
+ {classDescBox}
432
+ {classBelowBox}
433
+ {validation}
434
+ {style}
435
+ >
436
+ <div class="w-full flex">
437
+ <div
438
+ class={twMerge("stuic-cron-input-content", error && "has-error")}
439
+ data-presets-only={presetsOnly ? "" : undefined}
440
+ >
441
+ {#if _showPresets}
442
+ <select
443
+ class={twMerge("stuic-cron-input-preset", classPreset)}
444
+ bind:value={selectedPreset}
445
+ onchange={onPresetChange}
446
+ {disabled}
447
+ >
448
+ <option value="">Custom</option>
449
+ {#each _presets as p}
450
+ <option value={p.value}>{p.label}</option>
451
+ {/each}
452
+ </select>
453
+ {/if}
454
+
455
+ {#if _showFields}
456
+ <div class={twMerge("stuic-cron-input-fields", classFields)}>
457
+ {#each FIELD_DEFS as def}
458
+ <div class={twMerge("stuic-cron-input-field", classField)}>
459
+ <span class={twMerge("stuic-cron-input-field-label", classFieldLabel)}>
460
+ {def.label}
461
+ </span>
462
+ <input
463
+ type="text"
464
+ class={twMerge("stuic-cron-input-field-input", classFieldInput)}
465
+ bind:value={fields[def.key]}
466
+ oninput={onFieldInput}
467
+ placeholder={def.placeholder}
468
+ {disabled}
469
+ autocomplete="off"
470
+ spellcheck={false}
471
+ />
472
+ </div>
473
+ {/each}
474
+ </div>
475
+ {/if}
476
+
477
+ {#if _showRawInput}
478
+ <input
479
+ type="text"
480
+ class={twMerge("stuic-cron-input-raw", classRaw)}
481
+ bind:value={rawValue}
482
+ oninput={onRawInput}
483
+ placeholder="* * * * *"
484
+ {disabled}
485
+ autocomplete="off"
486
+ spellcheck={false}
487
+ />
488
+ {/if}
489
+
490
+ {#if (_showDescription || _showNextRun) && humanDescription}
491
+ <div class={twMerge("stuic-cron-input-summary", classSummary)}>
492
+ {humanDescription}
493
+ </div>
494
+ {/if}
495
+
496
+ </div>
497
+ {#if hasModeToggle}
498
+ <button
499
+ type="button"
500
+ class={twMerge(BTN_CLS, classToggleButton)}
501
+ onclick={toggleMode}
502
+ {disabled}
503
+ use:tooltip={() => ({
504
+ enabled: true,
505
+ content: mode === "predefined" ? "Manual input" : "Predefined presets",
506
+ })}
507
+ >
508
+ {#if mode === "predefined"}
509
+ {@html iconSlidersHorizontal({ size: 19 })}
510
+ {:else}
511
+ {@html iconList({ size: 19 })}
512
+ {/if}
513
+ </button>
514
+ {/if}
515
+ </div>
516
+ </InputWrap>
517
+ </div>
@@ -0,0 +1,56 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { ValidateOptions } from "../../actions/validate.svelte.js";
3
+ import type { THC } from "../Thc/Thc.svelte";
4
+ type SnippetWithId = Snippet<[{
5
+ id: string;
6
+ }]>;
7
+ export interface CronPreset {
8
+ label: string;
9
+ value: string;
10
+ }
11
+ export type CronInputMode = "predefined" | "manual";
12
+ export interface Props {
13
+ value?: string;
14
+ el?: HTMLElement;
15
+ id?: string;
16
+ mode?: CronInputMode;
17
+ label?: SnippetWithId | THC;
18
+ description?: SnippetWithId | THC;
19
+ renderSize?: "sm" | "md" | "lg" | string;
20
+ required?: boolean;
21
+ disabled?: boolean;
22
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
23
+ class?: string;
24
+ style?: string;
25
+ labelLeft?: boolean;
26
+ labelLeftWidth?: "normal" | "wide";
27
+ labelLeftBreakpoint?: number;
28
+ labelAfter?: SnippetWithId | THC;
29
+ below?: SnippetWithId | THC;
30
+ showPresets?: boolean;
31
+ showFields?: boolean;
32
+ showRawInput?: boolean;
33
+ showDescription?: boolean;
34
+ showNextRun?: boolean;
35
+ presets?: CronPreset[];
36
+ onchange?: (expression: string, valid: boolean) => void;
37
+ classLabel?: string;
38
+ classLabelBox?: string;
39
+ classInputBox?: string;
40
+ classInputBoxWrap?: string;
41
+ classInputBoxWrapInvalid?: string;
42
+ classDescBox?: string;
43
+ classBelowBox?: string;
44
+ classFields?: string;
45
+ classField?: string;
46
+ classFieldLabel?: string;
47
+ classFieldInput?: string;
48
+ classPreset?: string;
49
+ classRaw?: string;
50
+ classSummary?: string;
51
+ classToggleButton?: string;
52
+ }
53
+ export declare const DEFAULT_PRESETS: CronPreset[];
54
+ declare const CronInput: import("svelte").Component<Props, {}, "el" | "value" | "mode">;
55
+ type CronInput = ReturnType<typeof CronInput>;
56
+ export default CronInput;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A reactive helper that parses a cron expression and computes the next run time,
3
+ * updating automatically every minute.
4
+ */
5
+ export declare class CronNextRun {
6
+ #private;
7
+ constructor(expression?: string);
8
+ get expression(): string;
9
+ set expression(v: string);
10
+ /** The next Date when the cron expression matches, or null if invalid. */
11
+ get nextRun(): Date | null;
12
+ /** Formatted next run string (YYYY-MM-DD HH:MM), or empty string if invalid. */
13
+ get nextRunFormatted(): string;
14
+ /** Whether the current expression is valid. */
15
+ get valid(): boolean;
16
+ /** Stop the internal timer. Call when no longer needed. */
17
+ destroy(): void;
18
+ }
@@ -0,0 +1,62 @@
1
+ import { CronParser } from "@marianmeres/cron";
2
+ /**
3
+ * A reactive helper that parses a cron expression and computes the next run time,
4
+ * updating automatically every minute.
5
+ */
6
+ export class CronNextRun {
7
+ #expression = $state("");
8
+ #tick = $state(0);
9
+ #interval;
10
+ constructor(expression = "* * * * *") {
11
+ this.#expression = expression;
12
+ this.#interval = setInterval(() => this.#tick++, 60_000);
13
+ }
14
+ get expression() {
15
+ return this.#expression;
16
+ }
17
+ set expression(v) {
18
+ this.#expression = v;
19
+ }
20
+ /** The next Date when the cron expression matches, or null if invalid. */
21
+ get nextRun() {
22
+ // read tick to create reactive dependency
23
+ void this.#tick;
24
+ try {
25
+ const parser = new CronParser(this.#expression);
26
+ return parser.getNextRun();
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** Formatted next run string (YYYY-MM-DD HH:MM), or empty string if invalid. */
33
+ get nextRunFormatted() {
34
+ const next = this.nextRun;
35
+ if (!next)
36
+ return "";
37
+ return next.toLocaleString("sv-SE", {
38
+ year: "numeric",
39
+ month: "2-digit",
40
+ day: "2-digit",
41
+ hour: "2-digit",
42
+ minute: "2-digit",
43
+ });
44
+ }
45
+ /** Whether the current expression is valid. */
46
+ get valid() {
47
+ try {
48
+ new CronParser(this.#expression);
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /** Stop the internal timer. Call when no longer needed. */
56
+ destroy() {
57
+ if (this.#interval) {
58
+ clearInterval(this.#interval);
59
+ this.#interval = undefined;
60
+ }
61
+ }
62
+ }