@marianmeres/stuic 3.46.0 → 3.47.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -318,15 +318,9 @@
318
318
  $effect(() => {
319
319
  const current = activeSpread;
320
320
  clearTimeout(_settleTimer);
321
- // Small jump (±1): settle immediately (normal next/prev navigation)
322
- if (Math.abs(current - settledSpread) <= 1) {
321
+ _settleTimer = setTimeout(() => {
323
322
  settledSpread = current;
324
- } else {
325
- // Large jump (slider drag): debounce to avoid intermediate downloads
326
- _settleTimer = setTimeout(() => {
327
- settledSpread = current;
328
- }, 120);
329
- }
323
+ }, 120);
330
324
  });
331
325
 
332
326
  $effect(() => () => clearTimeout(_settleTimer));
@@ -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,63 @@
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
+ // the "sv-SE" is a trick/hack to get ISO 8601-ish local time (not UTC)
38
+ return next.toLocaleString("sv-SE", {
39
+ year: "numeric",
40
+ month: "2-digit",
41
+ day: "2-digit",
42
+ hour: "2-digit",
43
+ minute: "2-digit",
44
+ });
45
+ }
46
+ /** Whether the current expression is valid. */
47
+ get valid() {
48
+ try {
49
+ new CronParser(this.#expression);
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /** Stop the internal timer. Call when no longer needed. */
57
+ destroy() {
58
+ if (this.#interval) {
59
+ clearInterval(this.#interval);
60
+ this.#interval = undefined;
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,221 @@
1
+ /* ============================================================================
2
+ CRON INPUT COMPONENT TOKENS
3
+ Override globally: :root { --stuic-cron-input-fields-gap: 1rem; }
4
+ Override locally: <CronInput style="--stuic-cron-input-fields-gap: 1rem;">
5
+ ============================================================================ */
6
+
7
+ /* prettier-ignore */
8
+ :root {
9
+ --stuic-cron-input-fields-gap: 0.375rem;
10
+ --stuic-cron-input-section-gap: 0.25rem;
11
+ --stuic-cron-input-field-label-text: var(--stuic-color-muted-foreground);
12
+ --stuic-cron-input-summary-text: var(--stuic-color-muted-foreground);
13
+ --stuic-cron-input-error-text: var(--stuic-color-destructive);
14
+ --stuic-cron-input-field-bg: var(--stuic-color-background);
15
+ --stuic-cron-input-field-border: var(--stuic-color-border);
16
+ --stuic-cron-input-field-border-focus: var(--stuic-color-primary);
17
+ }
18
+
19
+ @layer components {
20
+ /* Content wrapper inside InputWrap — must fill the flex parent (.input-wrap > .flex) */
21
+ .stuic-cron-input-content {
22
+ display: flex;
23
+ flex-direction: column;
24
+ gap: var(--stuic-cron-input-section-gap);
25
+ flex: 1;
26
+ min-width: 0;
27
+ padding: 0.5rem;
28
+ }
29
+
30
+ /* No padding in presets-only mode (the select handles its own) */
31
+ .stuic-cron-input-content[data-presets-only] {
32
+ padding: 0;
33
+ }
34
+
35
+ /* Preset select — styled as inner sub-input by default */
36
+ .stuic-cron-input-preset {
37
+ display: block;
38
+ width: 100%;
39
+ appearance: none;
40
+ -webkit-appearance: none;
41
+ border: 1px solid var(--stuic-cron-input-field-border);
42
+ border-radius: var(--stuic-input-radius);
43
+ background: var(--stuic-cron-input-field-bg);
44
+ color: var(--stuic-input-text);
45
+ font-family: var(--stuic-input-font-family);
46
+ font-size: var(--stuic-input-font-size-sm);
47
+ padding: 0.375rem 2rem 0.375rem 0.5rem;
48
+ cursor: pointer;
49
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
50
+ background-position: right 0.5rem center;
51
+ background-repeat: no-repeat;
52
+ background-size: 1.5em 1.5em;
53
+ transition: border-color var(--stuic-input-transition);
54
+ }
55
+
56
+ .stuic-cron-input-preset:focus {
57
+ outline: none;
58
+ border-color: var(--stuic-cron-input-field-border-focus);
59
+ }
60
+
61
+ .stuic-cron-input-preset:disabled {
62
+ cursor: not-allowed;
63
+ opacity: 0.5;
64
+ }
65
+
66
+ /* Presets-only mode: select renders like a native FieldSelect (no inner border) */
67
+ .stuic-cron-input-content[data-presets-only] .stuic-cron-input-preset {
68
+ border: 0;
69
+ border-radius: 0;
70
+ background-color: transparent;
71
+ background-position: right 0.5rem center;
72
+ background-size: 1.5em 1.5em;
73
+ padding: var(--stuic-input-padding-y-md) var(--stuic-input-padding-x-md);
74
+ padding-right: 2.5rem;
75
+ font-size: var(--stuic-input-font-size-md);
76
+ min-height: var(--stuic-input-min-height-md);
77
+ }
78
+
79
+ .stuic-input[data-size="sm"]
80
+ .stuic-cron-input-content[data-presets-only]
81
+ .stuic-cron-input-preset {
82
+ padding: var(--stuic-input-padding-y-sm) var(--stuic-input-padding-x-sm);
83
+ padding-right: 2.5rem;
84
+ font-size: var(--stuic-input-font-size-sm);
85
+ min-height: var(--stuic-input-min-height-sm);
86
+ }
87
+
88
+ .stuic-input[data-size="lg"]
89
+ .stuic-cron-input-content[data-presets-only]
90
+ .stuic-cron-input-preset {
91
+ padding: var(--stuic-input-padding-y-lg) var(--stuic-input-padding-x-lg);
92
+ padding-right: 2.5rem;
93
+ font-size: var(--stuic-input-font-size-lg);
94
+ min-height: var(--stuic-input-min-height-lg);
95
+ }
96
+
97
+ .stuic-cron-input-content[data-presets-only] .stuic-cron-input-preset:focus {
98
+ border: 0;
99
+ }
100
+
101
+ /* 5-column grid for fields */
102
+ .stuic-cron-input-fields {
103
+ display: grid;
104
+ grid-template-columns: repeat(5, 1fr);
105
+ gap: var(--stuic-cron-input-fields-gap);
106
+ }
107
+
108
+ /* Individual field */
109
+ .stuic-cron-input-field {
110
+ display: flex;
111
+ flex-direction: column;
112
+ gap: 0.125rem;
113
+ }
114
+
115
+ /* Field label */
116
+ .stuic-cron-input-field-label {
117
+ font-size: 0.6875rem;
118
+ color: var(--stuic-cron-input-field-label-text);
119
+ text-transform: uppercase;
120
+ text-align: center;
121
+ letter-spacing: 0.05em;
122
+ }
123
+
124
+ /* Field input — must match base specificity of .stuic-input input:not(…)×4 */
125
+ .stuic-input
126
+ input.stuic-cron-input-field-input:not([type="checkbox"]):not([type="radio"]):not(
127
+ [type="range"]
128
+ ):not([type="file"]) {
129
+ display: block;
130
+ width: 100%;
131
+ border: 1px solid var(--stuic-cron-input-field-border);
132
+ border-radius: var(--stuic-input-radius);
133
+ background: var(--stuic-cron-input-field-bg);
134
+ color: var(--stuic-input-text);
135
+ font-family: var(--font-mono, monospace);
136
+ font-size: var(--stuic-input-font-size-sm);
137
+ padding: 0.25rem;
138
+ min-height: 0;
139
+ text-align: center;
140
+ transition: border-color var(--stuic-input-transition);
141
+ }
142
+
143
+ .stuic-cron-input-field-input:focus {
144
+ outline: none;
145
+ border-color: var(--stuic-cron-input-field-border-focus);
146
+ }
147
+
148
+ .stuic-cron-input-field-input:disabled {
149
+ cursor: not-allowed;
150
+ opacity: 0.5;
151
+ }
152
+
153
+ .stuic-cron-input-field-input::placeholder {
154
+ color: var(--stuic-input-placeholder);
155
+ opacity: 0.5;
156
+ font-family: var(--stuic-input-font-family);
157
+ }
158
+
159
+ /* Raw expression input */
160
+ .stuic-cron-input-raw {
161
+ display: block;
162
+ width: 100%;
163
+ border: 1px solid var(--stuic-cron-input-field-border);
164
+ border-radius: var(--stuic-input-radius);
165
+ background: var(--stuic-cron-input-field-bg);
166
+ color: var(--stuic-input-text);
167
+ font-family: var(--font-mono, monospace);
168
+ font-size: var(--stuic-input-font-size-sm);
169
+ padding: 0.375rem 0.5rem;
170
+ transition: border-color var(--stuic-input-transition);
171
+ }
172
+
173
+ .stuic-cron-input-raw:focus {
174
+ outline: none;
175
+ border-color: var(--stuic-cron-input-field-border-focus);
176
+ }
177
+
178
+ .stuic-cron-input-raw:disabled {
179
+ cursor: not-allowed;
180
+ opacity: 0.5;
181
+ }
182
+
183
+ .stuic-cron-input-raw::placeholder {
184
+ color: var(--stuic-input-placeholder);
185
+ opacity: 0.5;
186
+ }
187
+
188
+ /* Summary / human description */
189
+ .stuic-cron-input-summary {
190
+ color: var(--stuic-cron-input-summary-text);
191
+ font-size: 0.8125rem;
192
+ line-height: 1.4;
193
+ }
194
+
195
+ /* Error message */
196
+ .stuic-cron-input-error {
197
+ color: var(--stuic-cron-input-error-text);
198
+ font-size: 0.8125rem;
199
+ line-height: 1.4;
200
+ }
201
+
202
+ /* Mode toggle button */
203
+ .stuic-cron-input-content + .toggle-btn {
204
+ color: var(--stuic-input-localized-toggle-text);
205
+ transition:
206
+ background var(--stuic-input-transition),
207
+ color var(--stuic-input-transition);
208
+ }
209
+
210
+ .stuic-cron-input-content + .toggle-btn:hover:not(:disabled) {
211
+ color: var(--stuic-input-localized-toggle-text-hover);
212
+ background: var(--stuic-input-localized-toggle-hover-bg);
213
+ }
214
+
215
+ /* Responsive: stack on narrow */
216
+ @media (max-width: 400px) {
217
+ .stuic-cron-input-fields {
218
+ grid-template-columns: repeat(3, 1fr);
219
+ }
220
+ }
221
+ }
@@ -0,0 +1,2 @@
1
+ export { default as CronInput, type Props as CronInputProps, type CronPreset, type CronInputMode, DEFAULT_PRESETS as CRON_DEFAULT_PRESETS, } from "./CronInput.svelte";
2
+ export { CronNextRun } from "./cron-next-run.svelte.js";
@@ -0,0 +1,2 @@
1
+ export { default as CronInput, DEFAULT_PRESETS as CRON_DEFAULT_PRESETS, } from "./CronInput.svelte";
2
+ export { CronNextRun } from "./cron-next-run.svelte.js";
@@ -10,7 +10,7 @@
10
10
  --stuic-dropdown-menu-padding: calc(var(--spacing) * 1);
11
11
  --stuic-dropdown-menu-gap: calc(var(--spacing) * 0.5);
12
12
  --stuic-dropdown-menu-min-width: 12rem;
13
- --stuic-dropdown-menu-transition: 150ms;
13
+ --stuic-dropdown-menu-transition: 100ms;
14
14
 
15
15
  /* Dropdown container colors */
16
16
  --stuic-dropdown-menu-bg: var(--stuic-color-background);
@@ -39,8 +39,10 @@ export { iconLucideGrip as iconGrip } from "@marianmeres/icons-fns/lucide/iconLu
39
39
  export { iconLucideGripHorizontal as iconGripHorizontal } from "@marianmeres/icons-fns/lucide/iconLucideGripHorizontal.js";
40
40
  export { iconLucideGripVertical as iconGripVertical } from "@marianmeres/icons-fns/lucide/iconLucideGripVertical.js";
41
41
  export { iconLucideLanguages as iconLanguages } from "@marianmeres/icons-fns/lucide/iconLucideLanguages.js";
42
+ export { iconLucideList as iconList } from "@marianmeres/icons-fns/lucide/iconLucideList.js";
42
43
  export { iconLucideMenu as iconMenu } from "@marianmeres/icons-fns/lucide/iconLucideMenu.js";
43
44
  export { iconLucideSearch as iconSearch } from "@marianmeres/icons-fns/lucide/iconLucideSearch.js";
45
+ export { iconLucideSlidersHorizontal as iconSlidersHorizontal } from "@marianmeres/icons-fns/lucide/iconLucideSlidersHorizontal.js";
44
46
  export { iconLucideSettings as iconSettings } from "@marianmeres/icons-fns/lucide/iconLucideSettings.js";
45
47
  export { iconLucideSquare as iconSquare } from "@marianmeres/icons-fns/lucide/iconLucideSquare.js";
46
48
  export { iconLucideUser as iconUser } from "@marianmeres/icons-fns/lucide/iconLucideUser.js";
@@ -44,8 +44,10 @@ export { iconLucideGrip as iconGrip } from "@marianmeres/icons-fns/lucide/iconLu
44
44
  export { iconLucideGripHorizontal as iconGripHorizontal } from "@marianmeres/icons-fns/lucide/iconLucideGripHorizontal.js";
45
45
  export { iconLucideGripVertical as iconGripVertical } from "@marianmeres/icons-fns/lucide/iconLucideGripVertical.js";
46
46
  export { iconLucideLanguages as iconLanguages } from "@marianmeres/icons-fns/lucide/iconLucideLanguages.js";
47
+ export { iconLucideList as iconList } from "@marianmeres/icons-fns/lucide/iconLucideList.js";
47
48
  export { iconLucideMenu as iconMenu } from "@marianmeres/icons-fns/lucide/iconLucideMenu.js";
48
49
  export { iconLucideSearch as iconSearch } from "@marianmeres/icons-fns/lucide/iconLucideSearch.js";
50
+ export { iconLucideSlidersHorizontal as iconSlidersHorizontal } from "@marianmeres/icons-fns/lucide/iconLucideSlidersHorizontal.js";
49
51
  export { iconLucideSettings as iconSettings } from "@marianmeres/icons-fns/lucide/iconLucideSettings.js";
50
52
  export { iconLucideSquare as iconSquare } from "@marianmeres/icons-fns/lucide/iconLucideSquare.js";
51
53
  export { iconLucideUser as iconUser } from "@marianmeres/icons-fns/lucide/iconLucideUser.js";
package/dist/index.css CHANGED
@@ -37,6 +37,7 @@ In practice:
37
37
  @import "./components/LoginForm/index.css";
38
38
  @import "./components/Checkout/index.css";
39
39
  @import "./components/CommandMenu/index.css";
40
+ @import "./components/CronInput/index.css";
40
41
  @import "./components/DataTable/index.css";
41
42
  @import "./components/DismissibleMessage/index.css";
42
43
  @import "./components/DropdownMenu/index.css";
package/dist/index.d.ts CHANGED
@@ -37,6 +37,7 @@ export * from "./components/Checkout/index.js";
37
37
  export * from "./components/Collapsible/index.js";
38
38
  export * from "./components/ColorScheme/index.js";
39
39
  export * from "./components/CommandMenu/index.js";
40
+ export * from "./components/CronInput/index.js";
40
41
  export * from "./components/DataTable/index.js";
41
42
  export * from "./components/DismissibleMessage/index.js";
42
43
  export * from "./components/Drawer/index.js";
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ export * from "./components/Checkout/index.js";
38
38
  export * from "./components/Collapsible/index.js";
39
39
  export * from "./components/ColorScheme/index.js";
40
40
  export * from "./components/CommandMenu/index.js";
41
+ export * from "./components/CronInput/index.js";
41
42
  export * from "./components/DataTable/index.js";
42
43
  export * from "./components/DismissibleMessage/index.js";
43
44
  export * from "./components/Drawer/index.js";
package/package.json CHANGED
@@ -1,101 +1,100 @@
1
1
  {
2
- "name": "@marianmeres/stuic",
3
- "version": "3.46.0",
4
- "scripts": {
5
- "dev": "vite dev",
6
- "build": "vite build && pnpm run prepack",
7
- "preview": "vite preview",
8
- "prepare": "svelte-kit sync || echo ''",
9
- "prepack": "svelte-kit sync && svelte-package && publint",
10
- "package": "pnpm run prepack",
11
- "package:watch": "svelte-kit sync && svelte-package --watch && publint",
12
- "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
13
- "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
14
- "format": "prettier --write .",
15
- "lint": "eslint . && prettier --check .",
16
- "test": "vitest --dir src/",
17
- "svelte-check": "svelte-check",
18
- "svelte-package": "svelte-package",
19
- "rp": "pnpm run build && ./release.sh patch",
20
- "rpm": "pnpm run build && ./release.sh minor",
21
- "build:theme": "tsx scripts/generate-theme.ts",
22
- "build:theme:all": "pnpm run build:theme --indir=src/lib/themes --outdir=src/lib/themes/css"
23
- },
24
- "files": [
25
- "dist",
26
- "!dist/**/*.test.*",
27
- "!dist/**/*.spec.*",
28
- "docs",
29
- "AGENTS.md",
30
- "CLAUDE.md",
31
- "API.md"
32
- ],
33
- "sideEffects": [
34
- "**/*.css"
35
- ],
36
- "svelte": "./dist/index.js",
37
- "types": "./dist/index.d.ts",
38
- "type": "module",
39
- "exports": {
40
- ".": {
41
- "types": "./dist/index.d.ts",
42
- "svelte": "./dist/index.js"
43
- },
44
- "./utils": {
45
- "types": "./dist/utils/index.d.ts",
46
- "default": "./dist/utils/index.js"
47
- },
48
- "./themes/*": {
49
- "types": "./dist/themes/*.d.ts",
50
- "default": "./dist/themes/*.js"
51
- },
52
- "./themes/css/*": "./dist/themes/css/*",
53
- "./phone-validation": {
54
- "types": "./dist/components/Input/phone-validation.d.ts",
55
- "default": "./dist/components/Input/phone-validation.js"
56
- }
57
- },
58
- "peerDependencies": {
59
- "svelte": "^5.0.0"
60
- },
61
- "devDependencies": {
62
- "@eslint/js": "^9.39.4",
63
- "@marianmeres/random-human-readable": "^1.6.1",
64
- "@sveltejs/adapter-auto": "^4.0.0",
65
- "@sveltejs/kit": "^2.53.4",
66
- "@sveltejs/package": "^2.5.7",
67
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
68
- "@tailwindcss/cli": "^4.2.1",
69
- "@tailwindcss/forms": "^0.5.11",
70
- "@tailwindcss/typography": "^0.5.19",
71
- "@tailwindcss/vite": "^4.2.1",
72
- "@types/node": "^25.4.0",
73
- "dotenv": "^16.6.1",
74
- "eslint": "^9.39.4",
75
- "globals": "^16.5.0",
76
- "prettier": "^3.8.1",
77
- "prettier-plugin-svelte": "^3.5.1",
78
- "publint": "^0.3.18",
79
- "svelte": "^5.53.9",
80
- "svelte-check": "^4.4.5",
81
- "tailwindcss": "^4.2.1",
82
- "tsx": "^4.21.0",
83
- "typescript": "^5.9.3",
84
- "typescript-eslint": "^8.57.0",
85
- "vite": "^7.3.1",
86
- "vitest": "^3.2.4"
87
- },
88
- "dependencies": {
89
- "@marianmeres/clog": "^3.15.2",
90
- "@marianmeres/icons-fns": "^5.0.0",
91
- "@marianmeres/item-collection": "^1.3.5",
92
- "@marianmeres/paging-store": "^2.0.2",
93
- "@marianmeres/parse-boolean": "^2.0.5",
94
- "@marianmeres/ticker": "^1.16.5",
95
- "@marianmeres/tree": "^2.2.5",
96
- "esm-env": "^1.2.2",
97
- "libphonenumber-js": "^1.12.39",
98
- "runed": "^0.23.4",
99
- "tailwind-merge": "^3.5.0"
100
- }
101
- }
2
+ "name": "@marianmeres/stuic",
3
+ "version": "3.47.2",
4
+ "files": [
5
+ "dist",
6
+ "!dist/**/*.test.*",
7
+ "!dist/**/*.spec.*",
8
+ "docs",
9
+ "AGENTS.md",
10
+ "CLAUDE.md",
11
+ "API.md"
12
+ ],
13
+ "sideEffects": [
14
+ "**/*.css"
15
+ ],
16
+ "svelte": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "svelte": "./dist/index.js"
23
+ },
24
+ "./utils": {
25
+ "types": "./dist/utils/index.d.ts",
26
+ "default": "./dist/utils/index.js"
27
+ },
28
+ "./themes/*": {
29
+ "types": "./dist/themes/*.d.ts",
30
+ "default": "./dist/themes/*.js"
31
+ },
32
+ "./themes/css/*": "./dist/themes/css/*",
33
+ "./phone-validation": {
34
+ "types": "./dist/components/Input/phone-validation.d.ts",
35
+ "default": "./dist/components/Input/phone-validation.js"
36
+ }
37
+ },
38
+ "peerDependencies": {
39
+ "svelte": "^5.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@eslint/js": "^9.39.4",
43
+ "@marianmeres/random-human-readable": "^1.6.1",
44
+ "@sveltejs/adapter-auto": "^4.0.0",
45
+ "@sveltejs/kit": "^2.55.0",
46
+ "@sveltejs/package": "^2.5.7",
47
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
48
+ "@tailwindcss/cli": "^4.2.1",
49
+ "@tailwindcss/forms": "^0.5.11",
50
+ "@tailwindcss/typography": "^0.5.19",
51
+ "@tailwindcss/vite": "^4.2.1",
52
+ "@types/node": "^25.5.0",
53
+ "dotenv": "^16.6.1",
54
+ "eslint": "^9.39.4",
55
+ "globals": "^16.5.0",
56
+ "prettier": "^3.8.1",
57
+ "prettier-plugin-svelte": "^3.5.1",
58
+ "publint": "^0.3.18",
59
+ "svelte": "^5.53.11",
60
+ "svelte-check": "^4.4.5",
61
+ "tailwindcss": "^4.2.1",
62
+ "tsx": "^4.21.0",
63
+ "typescript": "^5.9.3",
64
+ "typescript-eslint": "^8.57.0",
65
+ "vite": "^7.3.1",
66
+ "vitest": "^3.2.4"
67
+ },
68
+ "dependencies": {
69
+ "@marianmeres/clog": "^3.15.2",
70
+ "@marianmeres/cron": "^1.1.0",
71
+ "@marianmeres/icons-fns": "^5.0.0",
72
+ "@marianmeres/item-collection": "^1.3.5",
73
+ "@marianmeres/paging-store": "^2.0.2",
74
+ "@marianmeres/parse-boolean": "^2.0.5",
75
+ "@marianmeres/ticker": "^1.16.5",
76
+ "@marianmeres/tree": "^2.2.5",
77
+ "esm-env": "^1.2.2",
78
+ "libphonenumber-js": "^1.12.40",
79
+ "runed": "^0.23.4",
80
+ "tailwind-merge": "^3.5.0"
81
+ },
82
+ "scripts": {
83
+ "dev": "vite dev",
84
+ "build": "vite build && pnpm run prepack",
85
+ "preview": "vite preview",
86
+ "package": "pnpm run prepack",
87
+ "package:watch": "svelte-kit sync && svelte-package --watch && publint",
88
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
89
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
90
+ "format": "prettier --write .",
91
+ "lint": "eslint . && prettier --check .",
92
+ "test": "vitest --dir src/",
93
+ "svelte-check": "svelte-check",
94
+ "svelte-package": "svelte-package",
95
+ "rp": "pnpm run build && ./release.sh patch",
96
+ "rpm": "pnpm run build && ./release.sh minor",
97
+ "build:theme": "tsx scripts/generate-theme.ts",
98
+ "build:theme:all": "pnpm run build:theme --indir=src/lib/themes --outdir=src/lib/themes/css"
99
+ }
100
+ }