Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "cron-builder-parser",
"private": true,
"version": "1.4.0",
"version": "1.4.1",
"description": "Strict POSIX cron: Builder & Parser",
"author": "Daschi (https://github.com/Daschi1)",
"repository": {
Expand Down
7 changes: 6 additions & 1 deletion src/lib/ui/CopyButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,10 @@
{label}
</Button>
{#if status}
<span class="ml-2 text-xs text-emerald-400" role="status" aria-live="polite">{status}</span>
<span
class={`ml-2 text-xs ${status === "Copied" ? "text-emerald-400" : "text-rose-400"}`}
role="status"
aria-live="polite"
aria-atomic="true">{status}</span
>
{/if}
196 changes: 175 additions & 21 deletions src/lib/ui/FieldChips.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import type { FieldSpec } from "$lib/utils/cron";
import { toggleValue, setEvery } from "$lib/utils/cron";
import { onMount, tick } from "svelte";

let {
title,
Expand All @@ -16,6 +17,136 @@
labels?: string[] | null;
}>();

type Item = {
key: string;
type: "every" | "value";
value?: number;
text: string; // visible text
title: string; // tooltip
};

let containerEl: HTMLDivElement | null = null;
let measureEl: HTMLDivElement | null = null;
let rows: number[][] = $state([]);
let items: Item[] = $state([]);
let widths: number[] = $state([]);
let containerWidth = $state(0);

const GAP_PX = 8; // Tailwind gap-2

function buildItems(): Item[] {
const out: Item[] = [];
out.push({
key: "every",
type: "every",
text: "Every *",
title: "Every *"
});
for (let v = min; v <= max; v++) {
const label = labels && labels[v - min] ? `${labels[v - min]} (${v})` : String(v);
out.push({
key: `v-${v}`,
type: "value",
value: v,
text: label,
title: labels && labels[v - min] ? `${labels[v - min]} (${v})` : `${v}`
});
}
return out;
}

async function measure() {
if (!measureEl) return;
// Ensure DOM is updated
await tick();
const btns = Array.from(measureEl.querySelectorAll("button"));
widths = btns.map((b) => Math.ceil(b.getBoundingClientRect().width));
}

function packRows() {
if (!containerWidth || widths.length !== items.length) {
rows = [items.map((_, i) => i)];
return;
}
const maxWidth = containerWidth;
const out: number[][] = [];
let i = 0;
let prevRowWidth = Number.POSITIVE_INFINITY;
while (i < items.length) {
let row: number[] = [];
let used = 0;
const limit = Math.min(maxWidth, prevRowWidth);
while (i < items.length) {
const w = widths[i];
const nextUsed = row.length === 0 ? w : used + GAP_PX + w;
if (nextUsed <= limit) {
row.push(i);
used = nextUsed;
i++;
} else {
break;
}
}
if (row.length === 0) {
// Fallback to place one item to avoid infinite loop
row.push(i);
used = widths[i] || 0;
i++;
}
out.push(row);
prevRowWidth = used;
}
rows = out;
}

function recalc() {
items = buildItems();
}

let ro: ResizeObserver | null = null;
onMount(() => {
recalc();
// Observe container for width changes (including responsive font-size changes)
ro = new ResizeObserver((entries) => {
for (const entry of entries) {
containerWidth = Math.floor(entry.contentRect.width);
// Re-measure because media queries may alter widths
measure().then(packRows);
}
});
if (containerEl) ro.observe(containerEl);
// Initial measure/pack
measure().then(packRows);
return () => {
if (ro && containerEl) ro.unobserve(containerEl);
ro = null;
};
});

// Recompute items and re-measure when props change
$effect(() => {
// Depend on labels, min, max
const _l = labels ? labels.join("|") : "";
const _min = min;
const _max = max;
void _l;
void _min;
void _max;
recalc();
// After items update, measure and pack
measure().then(packRows);
});

// Repack when selection may visually affect layout (unlikely, but safe)
$effect(() => {
const any = spec.any;
const count = spec.values.length;
void any;
void count;
// No need to rebuild items; just pack in case slight width changes occur
measure().then(packRows);
});

function onEvery() {
spec = setEvery(spec, min, max);
}
Expand All @@ -27,27 +158,50 @@

<div class="space-y-2">
<div class="text-sm font-semibold text-slate-300">{title}</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="chip cursor-pointer rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm font-medium transition-colors hover:bg-neutral-800 hover:brightness-110 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 focus-visible:outline-none aria-pressed:border-emerald-500 aria-pressed:bg-emerald-900/40 aria-pressed:text-emerald-200 md:px-2.5 md:py-1.5 md:text-xs"
aria-pressed={spec.any}
onclick={onEvery}
>Every *
</button>
{#each Array.from({ length: max - min + 1 }, (_, i) => min + i) as v (v)}
<button
type="button"
class="chip cursor-pointer rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm font-medium transition-colors hover:bg-neutral-800 hover:brightness-110 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 focus-visible:outline-none aria-pressed:border-emerald-500 aria-pressed:bg-emerald-900/40 aria-pressed:text-emerald-200 md:px-2.5 md:py-1.5 md:text-xs"
aria-pressed={!spec.any && spec.values.includes(v)}
onclick={() => onToggle(v)}
>
{#if labels && labels[v - min]}
{labels[v - min]} ({v})
{:else}
{v}
{/if}
</button>

<!-- Measurement container (offscreen, invisible) -->
<div class="invisible absolute top-0 left-0 -z-50">
<div class="flex gap-2" bind:this={measureEl} aria-hidden="true">
{#each items as it (it.key)}
<button
type="button"
class="chip cursor-pointer rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm font-medium md:px-2.5 md:py-1.5 md:text-xs"
>
{it.text}
</button>
{/each}
</div>
</div>

<!-- Visible rows container -->
<div class="flex w-full flex-col items-start gap-2" bind:this={containerEl}>
{#each rows as row, rIdx (rIdx)}
<div class="flex gap-2">
{#each row as idx (idx)}
{#if items[idx].type === "every"}
<button
type="button"
class="chip cursor-pointer rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-center text-sm font-medium transition-colors hover:bg-neutral-800 hover:brightness-110 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 focus-visible:outline-none aria-pressed:border-emerald-500 aria-pressed:bg-emerald-900/40 aria-pressed:text-emerald-200 md:px-2.5 md:py-1.5 md:text-xs"
aria-pressed={spec.any}
onclick={onEvery}
title={items[idx].title}
>{items[idx].text}
</button>
{:else}
<button
type="button"
class="chip cursor-pointer rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-center text-sm font-medium transition-colors hover:bg-neutral-800 hover:brightness-110 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 focus-visible:outline-none aria-pressed:border-emerald-500 aria-pressed:bg-emerald-900/40 aria-pressed:text-emerald-200 md:px-2.5 md:py-1.5 md:text-xs"
aria-pressed={!spec.any &&
items[idx].value !== undefined &&
spec.values.includes(items[idx].value)}
onclick={() => items[idx].value !== undefined && onToggle(items[idx].value)}
title={items[idx].title}
>
{items[idx].text}
</button>
{/if}
{/each}
</div>
{/each}
</div>
</div>