@flytedan/flytebot-design-system 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2757,6 +2757,48 @@ function useTriggerLabelling(label, id) {
2757
2757
 
2758
2758
  // src/components/forms/NumberInput.tsx
2759
2759
  import { jsx as jsx42, jsxs as jsxs36 } from "react/jsx-runtime";
2760
+ var HOLD_DELAY_MS = 400;
2761
+ var HOLD_BIG_AFTER = 6;
2762
+ var HOLD_FAST_AFTER = 12;
2763
+ var holdInterval = (repeats) => repeats > HOLD_FAST_AFTER ? 40 : repeats > HOLD_BIG_AFTER ? 70 : 120;
2764
+ function decimalPlaces(n) {
2765
+ if (!Number.isFinite(n)) return 0;
2766
+ const [mantissa, exp] = String(n).toLowerCase().split("e");
2767
+ const frac = (mantissa.split(".")[1] || "").length;
2768
+ return Math.max(0, frac - (exp ? Number(exp) : 0));
2769
+ }
2770
+ function parseNumberText(text) {
2771
+ const s = text.replace(/,/g, "").trim();
2772
+ if (s === "") return null;
2773
+ if (!/^-?(\d+\.?\d*|\.\d+)$/.test(s)) return NaN;
2774
+ return Number(s);
2775
+ }
2776
+ function allowedRange(min, max, integer) {
2777
+ return integer ? { lo: Math.ceil(min), hi: Math.floor(max) } : { lo: min, hi: max };
2778
+ }
2779
+ function stepNumber({ from, direction, amount, step, min, max, integer }) {
2780
+ const { lo, hi } = allowedRange(min, max, integer);
2781
+ if (lo > hi) return null;
2782
+ const base = from ?? 0;
2783
+ const anchor = Number.isFinite(lo) ? lo : 0;
2784
+ const unit = step > 0 ? step : 1;
2785
+ const travel = amount > 0 ? amount : unit;
2786
+ const magnitude = Math.max(Math.abs(base), Math.abs(anchor), travel, unit, 1);
2787
+ const maxPlaces = Math.max(0, Math.floor(Math.log10(Number.MAX_SAFE_INTEGER / (4 * magnitude))));
2788
+ const places = Math.min(maxPlaces, Math.max(decimalPlaces(base), decimalPlaces(anchor), decimalPlaces(travel), decimalPlaces(unit)));
2789
+ const scale = 10 ** places;
2790
+ const b = Math.round(base * scale);
2791
+ const a = Math.round(anchor * scale);
2792
+ const s = Math.round(unit * scale);
2793
+ const t = Math.round(travel * scale);
2794
+ const target = b + direction * t;
2795
+ let k = direction > 0 ? Math.floor((target - a) / s) : Math.ceil((target - a) / s);
2796
+ if (direction > 0 && a + k * s <= b) k = Math.floor((b - a) / s) + 1;
2797
+ if (direction < 0 && a + k * s >= b) k = Math.ceil((b - a) / s) - 1;
2798
+ let next = (a + k * s) / scale;
2799
+ if (integer && !Number.isInteger(next)) next = direction > 0 ? Math.ceil(next) : Math.floor(next);
2800
+ return Math.min(hi, Math.max(lo, next));
2801
+ }
2760
2802
  function NumberInput({
2761
2803
  label,
2762
2804
  help,
@@ -2769,8 +2811,10 @@ function NumberInput({
2769
2811
  max = Infinity,
2770
2812
  step = 1,
2771
2813
  bigStep,
2814
+ integer = false,
2772
2815
  value,
2773
2816
  onChange,
2817
+ onValidityChange,
2774
2818
  format = true,
2775
2819
  className = "",
2776
2820
  style,
@@ -2780,65 +2824,78 @@ function NumberInput({
2780
2824
  }) {
2781
2825
  const fieldId = useFieldId(id);
2782
2826
  const errorId = fieldId + "-error";
2783
- const num = (v) => {
2784
- const s = String(v == null ? "" : v).replace(/[^0-9.-]/g, "");
2785
- if (s === "" || s === "-") return null;
2786
- const n = Number(s);
2787
- return isNaN(n) ? null : n;
2788
- };
2789
- const requiresInteger = Number.isInteger(step);
2790
- const validationMessage = (n) => {
2791
- if (requiresInteger && !Number.isInteger(n)) return "Enter a whole number";
2792
- if (n < min) return "Must be \u2265 " + min;
2793
- if (n > max) return "Must be \u2264 " + max;
2794
- return null;
2827
+ const { lo, hi } = allowedRange(min, max, integer);
2828
+ const big = bigStep ?? Number((step * 10).toFixed(decimalPlaces(step)));
2829
+ const toText = (v) => v == null || v === "" ? "" : String(v);
2830
+ const toNumber = (v) => {
2831
+ if (v == null || v === "") return null;
2832
+ const n = typeof v === "number" ? v : parseNumberText(v);
2833
+ return n == null || Number.isNaN(n) ? null : n;
2795
2834
  };
2796
- const [text, setText] = React21.useState(value == null || value === "" ? "" : String(value));
2835
+ const [text, setText] = React21.useState(() => toText(value));
2797
2836
  const [editing, setEditing] = React21.useState(false);
2798
- const [invalidMessage, setInvalidMessage] = React21.useState(null);
2799
- const timer = React21.useRef(null);
2837
+ const parsed = parseNumberText(text);
2838
+ const invalidMessage = parsed == null ? null : Number.isNaN(parsed) ? "Enter a number" : integer && !Number.isInteger(parsed) ? "Enter a whole number" : parsed < lo ? "Must be \u2265 " + lo : parsed > hi ? "Must be \u2264 " + hi : null;
2839
+ const valid = invalidMessage == null;
2840
+ const committedRef = React21.useRef(toNumber(value));
2841
+ const onChangeRef = React21.useRef(onChange);
2842
+ onChangeRef.current = onChange;
2843
+ const onValidityChangeRef = React21.useRef(onValidityChange);
2844
+ onValidityChangeRef.current = onValidityChange;
2845
+ const focusedRef = React21.useRef(false);
2800
2846
  React21.useEffect(() => {
2801
- if (!editing) {
2802
- setText(value == null || value === "" ? "" : String(value));
2803
- setInvalidMessage(null);
2804
- }
2805
- }, [value, editing]);
2806
- const clamp = (n) => Math.min(max, Math.max(min, n));
2807
- const fire = (n) => onChange && onChange({ target: { value: n } });
2808
- const bump = (dir, mult = 1) => {
2809
- const cur = num(text) ?? num(value) ?? (min === -Infinity ? 0 : min);
2810
- const next = clamp(cur + dir * (bigStep && mult > 1 ? bigStep : step) * (bigStep ? 1 : mult));
2811
- setInvalidMessage(null);
2847
+ committedRef.current = toNumber(value);
2848
+ if (!focusedRef.current) setText(toText(value));
2849
+ }, [value]);
2850
+ React21.useLayoutEffect(() => {
2851
+ onValidityChangeRef.current?.(valid);
2852
+ }, [valid]);
2853
+ const fire = (n) => {
2854
+ committedRef.current = n ?? null;
2855
+ onChangeRef.current?.({ target: { value: n } });
2856
+ };
2857
+ const bump = (direction, amount) => {
2858
+ const next = stepNumber({ from: committedRef.current, direction, amount, step, min, max, integer });
2859
+ if (next == null) return false;
2812
2860
  setText(String(next));
2861
+ if (next === committedRef.current) return false;
2813
2862
  fire(next);
2863
+ return true;
2814
2864
  };
2815
- const hold = (dir) => {
2816
- bump(dir);
2817
- let n = 0;
2865
+ const bumpRef = React21.useRef(bump);
2866
+ bumpRef.current = bump;
2867
+ const holdTimer = React21.useRef(null);
2868
+ const release = () => {
2869
+ if (holdTimer.current) clearTimeout(holdTimer.current);
2870
+ holdTimer.current = null;
2871
+ };
2872
+ const hold = (direction) => {
2873
+ release();
2874
+ bump(direction, step);
2875
+ let repeats = 0;
2818
2876
  const tick = () => {
2819
- n++;
2820
- bump(dir, n > 6 ? 4 : 1);
2821
- timer.current = setTimeout(tick, n > 12 ? 40 : n > 6 ? 70 : 120);
2877
+ repeats++;
2878
+ if (!bumpRef.current(direction, repeats > HOLD_BIG_AFTER ? big : step)) {
2879
+ release();
2880
+ return;
2881
+ }
2882
+ holdTimer.current = setTimeout(tick, holdInterval(repeats));
2822
2883
  };
2823
- timer.current = setTimeout(tick, 420);
2824
- };
2825
- const release = () => {
2826
- if (timer.current) clearTimeout(timer.current);
2884
+ holdTimer.current = setTimeout(tick, HOLD_DELAY_MS);
2827
2885
  };
2828
2886
  React21.useEffect(() => () => {
2829
- if (timer.current) clearTimeout(timer.current);
2887
+ if (holdTimer.current) clearTimeout(holdTimer.current);
2830
2888
  }, []);
2831
- const shown = editing ? text : (() => {
2832
- const n = num(text);
2833
- return n == null ? "" : format ? n.toLocaleString() : String(n);
2834
- })();
2889
+ const current2 = committedRef.current;
2890
+ const shown = editing || !valid ? text : parsed == null ? "" : format ? parsed.toLocaleString() : String(parsed);
2835
2891
  const shownError = error || invalidMessage || void 0;
2836
2892
  const box = ["fd-input", "fd-input-num", shownError ? "fd-input-error" : "", disabled ? "fd-input-disabled" : ""].filter(Boolean).join(" ");
2837
2893
  const fieldName = label || ariaLabel;
2838
2894
  const stepperName = (verb) => fieldName ? verb + " " + fieldName : verb;
2895
+ const noRange = lo > hi;
2839
2896
  return /* @__PURE__ */ jsxs36("div", { className: ["fd-field", className].filter(Boolean).join(" "), style, children: [
2840
2897
  label ? /* @__PURE__ */ jsx42(FieldLabel, { htmlFor: fieldId, required, children: label }) : null,
2841
- /* @__PURE__ */ jsxs36("div", { className: box, style: { gap: 8 }, children: [
2898
+ /* @__PURE__ */ jsxs36("div", { className: box, children: [
2842
2899
  prefix ? /* @__PURE__ */ jsx42("span", { className: "fd-input-affix", children: prefix }) : null,
2843
2900
  /* @__PURE__ */ jsx42(
2844
2901
  "input",
@@ -2851,52 +2908,54 @@ function NumberInput({
2851
2908
  value: shown,
2852
2909
  "aria-invalid": shownError ? "true" : void 0,
2853
2910
  "aria-describedby": shownError ? errorId : void 0,
2854
- onFocus: () => setEditing(true),
2911
+ onFocus: () => {
2912
+ focusedRef.current = true;
2913
+ setEditing(true);
2914
+ },
2855
2915
  onBlur: () => {
2916
+ focusedRef.current = false;
2856
2917
  setEditing(false);
2857
- if (text.trim() === "") fire(void 0);
2918
+ if (text.trim() === "" && committedRef.current != null) fire(void 0);
2858
2919
  },
2859
2920
  onChange: (e) => {
2860
2921
  const raw = e.target.value;
2861
2922
  setText(raw);
2862
- if (raw.trim() === "") {
2863
- setInvalidMessage(null);
2864
- fire(void 0);
2865
- return;
2866
- }
2867
- const n = num(raw);
2923
+ const n = parseNumberText(raw);
2868
2924
  if (n == null) {
2869
- setInvalidMessage("Enter a number");
2925
+ if (committedRef.current != null) fire(void 0);
2870
2926
  return;
2871
2927
  }
2872
- const message = validationMessage(n);
2873
- setInvalidMessage(message);
2874
- if (!message) fire(n);
2928
+ if (Number.isNaN(n) || integer && !Number.isInteger(n) || n < lo || n > hi) return;
2929
+ fire(n);
2875
2930
  },
2876
2931
  onKeyDown: (e) => {
2877
2932
  if (e.key === "ArrowUp") {
2878
2933
  e.preventDefault();
2879
- bump(1, e.shiftKey ? 2 : 1);
2934
+ bump(1, e.shiftKey ? big : step);
2880
2935
  } else if (e.key === "ArrowDown") {
2881
2936
  e.preventDefault();
2882
- bump(-1, e.shiftKey ? 2 : 1);
2937
+ bump(-1, e.shiftKey ? big : step);
2883
2938
  }
2884
2939
  }
2885
2940
  }
2886
2941
  ),
2887
2942
  suffix ? /* @__PURE__ */ jsx42("span", { className: "fd-input-affix", children: suffix }) : null,
2888
- /* @__PURE__ */ jsxs36("span", { className: "fd-row", style: { gap: 4, flex: "none" }, children: [
2943
+ /* @__PURE__ */ jsxs36("span", { className: "fd-input-steppers", children: [
2889
2944
  /* @__PURE__ */ jsx42(
2890
2945
  "button",
2891
2946
  {
2892
2947
  type: "button",
2893
2948
  className: "fd-stepbtn",
2894
2949
  "aria-label": stepperName("Decrease"),
2895
- disabled: disabled || (num(text) ?? 0) <= min,
2950
+ disabled: disabled || noRange || current2 != null && current2 <= lo,
2896
2951
  onPointerDown: () => hold(-1),
2897
2952
  onPointerUp: release,
2898
2953
  onPointerLeave: release,
2899
- children: /* @__PURE__ */ jsx42("i", { className: "ph ph-minus" })
2954
+ onPointerCancel: release,
2955
+ onClick: (e) => {
2956
+ if (e.detail === 0) bump(-1, step);
2957
+ },
2958
+ children: /* @__PURE__ */ jsx42("i", { className: "ph ph-minus", "aria-hidden": "true" })
2900
2959
  }
2901
2960
  ),
2902
2961
  /* @__PURE__ */ jsx42(
@@ -2905,11 +2964,15 @@ function NumberInput({
2905
2964
  type: "button",
2906
2965
  className: "fd-stepbtn",
2907
2966
  "aria-label": stepperName("Increase"),
2908
- disabled: disabled || (num(text) ?? 0) >= max,
2967
+ disabled: disabled || noRange || current2 != null && current2 >= hi,
2909
2968
  onPointerDown: () => hold(1),
2910
2969
  onPointerUp: release,
2911
2970
  onPointerLeave: release,
2912
- children: /* @__PURE__ */ jsx42("i", { className: "ph ph-plus" })
2971
+ onPointerCancel: release,
2972
+ onClick: (e) => {
2973
+ if (e.detail === 0) bump(1, step);
2974
+ },
2975
+ children: /* @__PURE__ */ jsx42("i", { className: "ph ph-plus", "aria-hidden": "true" })
2913
2976
  }
2914
2977
  )
2915
2978
  ] })
@@ -3194,6 +3257,97 @@ function isFieldSet(field, value) {
3194
3257
  }
3195
3258
  }
3196
3259
  }
3260
+ var MAX_WITHIN_DAYS = 3650;
3261
+ function effectiveMaxDays(field) {
3262
+ return Math.min(field.maxDays ?? MAX_WITHIN_DAYS, MAX_WITHIN_DAYS);
3263
+ }
3264
+ function isIsoDate(v) {
3265
+ if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
3266
+ const d = /* @__PURE__ */ new Date(v + "T00:00:00Z");
3267
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
3268
+ }
3269
+ var isOption = (o) => !!o && typeof o.value === "string" && typeof o.label === "string";
3270
+ function validateFilterValue(field, value) {
3271
+ switch (field.type) {
3272
+ case "text":
3273
+ return typeof value === "string" ? { ok: true } : { ok: false, reason: "Enter text" };
3274
+ case "select":
3275
+ return field.options.some((o) => o.value === value) ? { ok: true } : { ok: false, reason: "Pick one of the options" };
3276
+ case "multiSelect": {
3277
+ const v = value;
3278
+ if (!v || !Array.isArray(v.values) || v.values.length === 0) {
3279
+ return { ok: false, reason: "Select at least one value" };
3280
+ }
3281
+ if (!v.values.every(isOption)) return { ok: false, reason: "Each value needs a value and a label" };
3282
+ if (field.maxValues != null && v.values.length > field.maxValues) {
3283
+ return { ok: false, reason: "Select at most " + field.maxValues + " values" };
3284
+ }
3285
+ if (!operatorsFor(field).includes(v.operator)) {
3286
+ return { ok: false, reason: "That operator isn't allowed for " + field.label };
3287
+ }
3288
+ return { ok: true };
3289
+ }
3290
+ case "range": {
3291
+ const v = value;
3292
+ if (!v || v.min == null && v.max == null) {
3293
+ return { ok: false, reason: "Enter at least one bound" };
3294
+ }
3295
+ const lo = field.min ?? -Infinity;
3296
+ const hi = field.max ?? Infinity;
3297
+ for (const n of [v.min, v.max]) {
3298
+ if (n == null) continue;
3299
+ if (typeof n !== "number" || !Number.isFinite(n)) return { ok: false, reason: "Enter a number" };
3300
+ if (field.integer && !Number.isInteger(n)) return { ok: false, reason: "Enter a whole number" };
3301
+ if (n < lo) return { ok: false, reason: "Must be \u2265 " + lo };
3302
+ if (n > hi) return { ok: false, reason: "Must be \u2264 " + hi };
3303
+ }
3304
+ if (v.min != null && v.max != null && v.min > v.max) {
3305
+ return { ok: false, reason: "Must be \u2265 " + v.min };
3306
+ }
3307
+ return { ok: true };
3308
+ }
3309
+ case "date": {
3310
+ const v = value;
3311
+ if (!v) return { ok: false, reason: "Pick a date value" };
3312
+ if (v.kind === "withinDays") {
3313
+ const maxDays = effectiveMaxDays(field);
3314
+ if (v.days == null) return { ok: false, reason: "Enter a number of days" };
3315
+ if (!Number.isInteger(v.days)) return { ok: false, reason: "Enter a whole number" };
3316
+ if (v.days < 1) return { ok: false, reason: "Must be \u2265 1" };
3317
+ if (v.days > maxDays) return { ok: false, reason: "Must be \u2264 " + maxDays };
3318
+ return { ok: true };
3319
+ }
3320
+ if (v.kind !== "between") return { ok: false, reason: "Pick a date value" };
3321
+ if (!v.from || !v.to) return { ok: false, reason: "Pick both dates" };
3322
+ if (!isIsoDate(v.from) || !isIsoDate(v.to)) return { ok: false, reason: "Dates must be YYYY-MM-DD" };
3323
+ if (v.from > v.to) return { ok: false, reason: "The start date must be on or before the end date" };
3324
+ return { ok: true };
3325
+ }
3326
+ }
3327
+ }
3328
+ function isEmptyCandidate(field, candidate) {
3329
+ return field.type !== "text" && field.type !== "select" && !isFieldSet(field, candidate);
3330
+ }
3331
+ function assertValidFieldDefinition(field) {
3332
+ if (field.type === "multiSelect" && field.maxValues != null && (!Number.isInteger(field.maxValues) || field.maxValues < 1)) {
3333
+ throw new Error(
3334
+ 'FilterBar: multiSelect field "' + field.key + '" has an invalid maxValues (' + field.maxValues + ") \u2014 must be a whole number of at least 1."
3335
+ );
3336
+ }
3337
+ if (field.type !== "date") return;
3338
+ const defaultDays = field.defaultDays ?? 30;
3339
+ const maxDays = field.maxDays ?? MAX_WITHIN_DAYS;
3340
+ if (!Number.isInteger(maxDays) || maxDays < 1 || maxDays > MAX_WITHIN_DAYS) {
3341
+ throw new Error(
3342
+ 'FilterBar: date field "' + field.key + '" has an invalid maxDays (' + field.maxDays + ") \u2014 must be a whole number from 1 to " + MAX_WITHIN_DAYS + "."
3343
+ );
3344
+ }
3345
+ if (!Number.isInteger(defaultDays) || defaultDays < 1 || defaultDays > maxDays) {
3346
+ throw new Error(
3347
+ 'FilterBar: date field "' + field.key + '" has an invalid defaultDays (' + field.defaultDays + ") \u2014 must be a whole number from 1 to maxDays (" + maxDays + ")."
3348
+ );
3349
+ }
3350
+ }
3197
3351
  function chipLabel(field, value) {
3198
3352
  switch (field.type) {
3199
3353
  case "text":
@@ -3246,8 +3400,7 @@ function MultiSelectPanel({
3246
3400
  field,
3247
3401
  value,
3248
3402
  anchorRef,
3249
- onChange,
3250
- onRemove,
3403
+ onCommit,
3251
3404
  onClose
3252
3405
  }) {
3253
3406
  const allowedOps = operatorsFor(field);
@@ -3256,13 +3409,21 @@ function MultiSelectPanel({
3256
3409
  const selected = value?.values ?? [];
3257
3410
  const selectedRef = React24.useRef(selected);
3258
3411
  selectedRef.current = selected;
3412
+ const operatorRef = React24.useRef(operator);
3413
+ operatorRef.current = operator;
3414
+ const mountedRef = React24.useRef(true);
3415
+ React24.useEffect(() => {
3416
+ mountedRef.current = true;
3417
+ return () => {
3418
+ mountedRef.current = false;
3419
+ };
3420
+ }, []);
3259
3421
  const commit = (nextValues, nextOperator = operator) => {
3260
- if (nextValues.length === 0) onRemove();
3261
- else onChange({ operator: nextOperator, values: nextValues });
3422
+ onCommit({ operator: nextOperator, values: nextValues });
3262
3423
  };
3263
3424
  const setOperator = (op) => {
3264
3425
  setDraftOperator(op);
3265
- if (selected.length > 0) commit(selected, op);
3426
+ if (selectedRef.current.length > 0) commit(selectedRef.current, op);
3266
3427
  };
3267
3428
  const [mode2, setMode] = React24.useState("search");
3268
3429
  const [query, setQuery] = React24.useState("");
@@ -3302,9 +3463,12 @@ function MultiSelectPanel({
3302
3463
  const showError = !!field.loadOptions && asyncState.error;
3303
3464
  const hasListbox = !showLoading && !showError && options.length > 0;
3304
3465
  const isSelected = (o) => selected.some((s) => s.value === o.value);
3466
+ const atLimit = field.maxValues != null && selected.length >= field.maxValues;
3305
3467
  const toggle = (o) => {
3306
- const next = isSelected(o) ? selected.filter((s) => s.value !== o.value) : [...selected, o];
3307
- commit(next);
3468
+ const current2 = selectedRef.current;
3469
+ const on = current2.some((s) => s.value === o.value);
3470
+ if (!on && field.maxValues != null && current2.length >= field.maxValues) return;
3471
+ commit(on ? current2.filter((s) => s.value !== o.value) : [...current2, o]);
3308
3472
  };
3309
3473
  const nav = useListboxNav({ count: options.length, onSelect: (i) => toggle(options[i]) });
3310
3474
  useListboxScrollIntoView(nav.active, listRef, hasListbox);
@@ -3312,8 +3476,7 @@ function MultiSelectPanel({
3312
3476
  nav.setActive(0);
3313
3477
  }, [query, options.length]);
3314
3478
  const activeId = hasListbox && options[nav.active] ? listId + "-opt-" + nav.active : void 0;
3315
- const liveRole = showError ? "alert" : "status";
3316
- const liveMessage = showLoading ? "Loading\u2026" : showError ? "Couldn't load options." : options.length === 0 ? "No matches" : "";
3479
+ const liveMessage = showLoading ? "Loading\u2026" : showError ? "Couldn't load options." : options.length === 0 ? "No matches" : atLimit ? "Limit reached: at most " + field.maxValues + " values" : "";
3317
3480
  const [pasteText, setPasteText] = React24.useState("");
3318
3481
  const [pasteLoading, setPasteLoading] = React24.useState(false);
3319
3482
  const [unmatchedTokens, setUnmatchedTokens] = React24.useState([]);
@@ -3329,13 +3492,19 @@ function MultiSelectPanel({
3329
3492
  setPasteLoading(true);
3330
3493
  setPasteError(null);
3331
3494
  field.resolve(tokens).then((result) => {
3495
+ if (!mountedRef.current) return;
3332
3496
  setPasteLoading(false);
3333
3497
  setUnmatchedTokens(result.unmatched);
3334
3498
  const merged = new Map(selectedRef.current.map((s) => [s.value, s]));
3335
3499
  for (const m of result.matched) merged.set(m.value, { value: m.value, label: m.label });
3336
- commit(Array.from(merged.values()));
3500
+ if (field.maxValues != null && merged.size > field.maxValues) {
3501
+ setPasteError("This filter allows at most " + field.maxValues + " values; adding this list would make " + merged.size + ".");
3502
+ return;
3503
+ }
3504
+ commit(Array.from(merged.values()), operatorRef.current);
3337
3505
  setPasteText("");
3338
- }).catch((err) => {
3506
+ }, (err) => {
3507
+ if (!mountedRef.current) return;
3339
3508
  setPasteLoading(false);
3340
3509
  setPasteError(err instanceof Error ? err.message : "Could not resolve the pasted list.");
3341
3510
  });
@@ -3413,14 +3582,14 @@ function MultiSelectPanel({
3413
3582
  selected.length,
3414
3583
  " matched"
3415
3584
  ] }),
3416
- /* @__PURE__ */ jsx45("span", { className: "fd-row fd-wrap fd-gap-xs", children: selected.map((v) => /* @__PURE__ */ jsx45(Tag, { onRemove: () => commit(selected.filter((s) => s.value !== v.value)), children: v.label }, v.value)) })
3585
+ /* @__PURE__ */ jsx45("span", { className: "fd-row fd-wrap fd-gap-xs", children: selected.map((v) => /* @__PURE__ */ jsx45(Tag, { onRemove: () => commit(selectedRef.current.filter((s) => s.value !== v.value)), children: v.label }, v.value)) })
3417
3586
  ] }) : null
3418
3587
  ] }) : /* @__PURE__ */ jsxs39("span", { className: "fd-stack fd-gap-sm", children: [
3419
3588
  /* @__PURE__ */ jsxs39(
3420
3589
  "span",
3421
3590
  {
3422
3591
  className: "fd-row fd-gap-xs" + (liveMessage && !showError ? " fd-muted" : ""),
3423
- role: liveRole,
3592
+ role: "status",
3424
3593
  children: [
3425
3594
  showLoading ? /* @__PURE__ */ jsx45("span", { className: "fd-spinner", "aria-hidden": "true" }) : null,
3426
3595
  liveMessage ? /* @__PURE__ */ jsx45("span", { className: "fd-body-sm", children: liveMessage }) : null
@@ -3430,6 +3599,7 @@ function MultiSelectPanel({
3430
3599
  showError ? /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: () => setRetryTick((n) => n + 1), children: "Retry" }) : null,
3431
3600
  hasListbox ? /* @__PURE__ */ jsx45("span", { id: listId, role: "listbox", "aria-label": field.label, "aria-multiselectable": "true", ref: listRef, className: "fd-pop-list", children: options.map((o, i) => {
3432
3601
  const on = isSelected(o);
3602
+ const blocked = !on && atLimit;
3433
3603
  return /* @__PURE__ */ jsxs39(
3434
3604
  "div",
3435
3605
  {
@@ -3438,6 +3608,7 @@ function MultiSelectPanel({
3438
3608
  role: "option",
3439
3609
  tabIndex: -1,
3440
3610
  "aria-selected": on,
3611
+ "aria-disabled": blocked ? "true" : void 0,
3441
3612
  className: ["fd-opt", i === nav.active ? "is-active" : "", on ? "is-selected" : ""].filter(Boolean).join(" "),
3442
3613
  onMouseEnter: () => nav.setActive(i),
3443
3614
  onClick: () => toggle(o),
@@ -3471,22 +3642,24 @@ function RangePanel({
3471
3642
  field,
3472
3643
  value,
3473
3644
  anchorRef,
3474
- onChange,
3475
- onRemove,
3645
+ onCommit,
3476
3646
  onClose
3477
3647
  }) {
3478
- const [draftMin, setDraftMin] = React24.useState(value?.min);
3479
- const [draftMax, setDraftMax] = React24.useState(value?.max);
3480
- const invalid = draftMin != null && draftMax != null && draftMin > draftMax;
3481
- const apply = (nextMin, nextMax) => {
3482
- setDraftMin(nextMin);
3483
- setDraftMax(nextMax);
3484
- if (nextMin == null && nextMax == null) {
3485
- onRemove();
3486
- return;
3487
- }
3488
- if (nextMin != null && nextMax != null && nextMin > nextMax) return;
3489
- onChange({ min: nextMin, max: nextMax });
3648
+ const [draft, setDraft] = React24.useState({ min: value?.min, max: value?.max });
3649
+ const draftRef = React24.useRef(draft);
3650
+ const [minTextValid, setMinTextValid] = React24.useState(true);
3651
+ const [maxTextValid, setMaxTextValid] = React24.useState(true);
3652
+ const draftMin = draft.min;
3653
+ const draftMax = draft.max;
3654
+ const isEmpty = draftMin == null && draftMax == null;
3655
+ const validation = isEmpty ? { ok: true } : validateFilterValue(field, { min: draftMin, max: draftMax });
3656
+ const comboError = !isEmpty && !validation.ok ? validation.reason : void 0;
3657
+ const doneDisabled = !minTextValid || !maxTextValid || !!comboError;
3658
+ const apply = (patch) => {
3659
+ const next = { ...draftRef.current, ...patch };
3660
+ draftRef.current = next;
3661
+ setDraft(next);
3662
+ onCommit(next.min == null && next.max == null ? void 0 : next);
3490
3663
  };
3491
3664
  return /* @__PURE__ */ jsx45(
3492
3665
  Popover,
@@ -3498,7 +3671,7 @@ function RangePanel({
3498
3671
  offset: 6,
3499
3672
  width: PANEL_WIDTH.range,
3500
3673
  label: field.label,
3501
- footer: /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: onClose, disabled: invalid, children: "Done" }),
3674
+ footer: /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: onClose, disabled: doneDisabled, children: "Done" }),
3502
3675
  children: /* @__PURE__ */ jsxs39("span", { className: "fd-stack fd-gap-sm", children: [
3503
3676
  /* @__PURE__ */ jsx45(
3504
3677
  NumberInput,
@@ -3508,8 +3681,10 @@ function RangePanel({
3508
3681
  min: field.min,
3509
3682
  max: field.max,
3510
3683
  step: field.step,
3684
+ integer: field.integer,
3511
3685
  placeholder: field.minPlaceholder,
3512
- onChange: (e) => apply(e.target.value, draftMax)
3686
+ onChange: (e) => apply({ min: e.target.value }),
3687
+ onValidityChange: setMinTextValid
3513
3688
  }
3514
3689
  ),
3515
3690
  /* @__PURE__ */ jsx45(
@@ -3520,9 +3695,11 @@ function RangePanel({
3520
3695
  min: field.min,
3521
3696
  max: field.max,
3522
3697
  step: field.step,
3523
- error: invalid ? "Must be \u2265 " + draftMin : void 0,
3698
+ integer: field.integer,
3699
+ error: comboError,
3524
3700
  placeholder: field.maxPlaceholder,
3525
- onChange: (e) => apply(draftMin, e.target.value)
3701
+ onChange: (e) => apply({ max: e.target.value }),
3702
+ onValidityChange: setMaxTextValid
3526
3703
  }
3527
3704
  )
3528
3705
  ] })
@@ -3533,12 +3710,12 @@ function DatePanel({
3533
3710
  field,
3534
3711
  value,
3535
3712
  anchorRef,
3536
- onChange,
3537
- onRemove,
3713
+ onCommit,
3538
3714
  onClose
3539
3715
  }) {
3540
3716
  const defaultDays = field.defaultDays ?? 30;
3541
- const maxDays = field.maxDays ?? 3650;
3717
+ const maxDays = effectiveMaxDays(field);
3718
+ const [daysTextValid, setDaysTextValid] = React24.useState(true);
3542
3719
  const [kind, setKind] = React24.useState(value?.kind ?? "withinDays");
3543
3720
  const [daysDraft, setDaysDraft] = React24.useState(
3544
3721
  value?.kind === "withinDays" ? value.days : defaultDays
@@ -3546,42 +3723,26 @@ function DatePanel({
3546
3723
  const [between, setBetween] = React24.useState(
3547
3724
  value?.kind === "between" ? { from: value.from, to: value.to } : { from: "", to: "" }
3548
3725
  );
3549
- const committedRef = React24.useRef(!!value);
3550
3726
  const applyWithinDays = (days) => {
3551
3727
  setDaysDraft(days);
3552
- if (days != null) {
3553
- committedRef.current = true;
3554
- onChange({ kind: "withinDays", days });
3555
- } else if (committedRef.current) {
3556
- committedRef.current = false;
3557
- onRemove();
3558
- }
3728
+ onCommit(days == null ? void 0 : { kind: "withinDays", days });
3559
3729
  };
3560
3730
  const applyBetween = (from, to) => {
3561
3731
  setBetween({ from, to });
3562
- if (from && to) {
3563
- committedRef.current = true;
3564
- onChange({ kind: "between", from, to });
3565
- } else if (committedRef.current) {
3566
- committedRef.current = false;
3567
- onRemove();
3568
- }
3732
+ onCommit(from && to ? { kind: "between", from, to } : void 0);
3569
3733
  };
3570
3734
  const switchKind = (next) => {
3735
+ if (next === kind) return;
3571
3736
  setKind(next);
3572
- if (next === "between") {
3573
- setBetween({ from: "", to: "" });
3574
- if (committedRef.current) {
3575
- committedRef.current = false;
3576
- onRemove();
3577
- }
3578
- }
3737
+ if (next === "between") setBetween({ from: "", to: "" });
3738
+ else setDaysDraft(defaultDays);
3739
+ setDaysTextValid(true);
3740
+ onCommit(void 0);
3579
3741
  };
3742
+ const doneDisabled = kind === "withinDays" && !daysTextValid;
3580
3743
  const confirm = () => {
3581
- if (kind === "withinDays" && daysDraft != null) {
3582
- committedRef.current = true;
3583
- onChange({ kind: "withinDays", days: daysDraft });
3584
- }
3744
+ if (doneDisabled) return;
3745
+ if (kind === "withinDays" && daysDraft != null) onCommit({ kind: "withinDays", days: daysDraft });
3585
3746
  onClose();
3586
3747
  };
3587
3748
  return /* @__PURE__ */ jsx45(
@@ -3594,7 +3755,7 @@ function DatePanel({
3594
3755
  offset: 6,
3595
3756
  width: PANEL_WIDTH.date,
3596
3757
  label: field.label,
3597
- footer: /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: confirm, children: "Done" }),
3758
+ footer: /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: confirm, disabled: doneDisabled, children: "Done" }),
3598
3759
  children: /* @__PURE__ */ jsxs39("span", { className: "fd-stack fd-gap-sm", children: [
3599
3760
  /* @__PURE__ */ jsx45(
3600
3761
  SegmentedControl,
@@ -3608,7 +3769,19 @@ function DatePanel({
3608
3769
  onChange: (k) => switchKind(k)
3609
3770
  }
3610
3771
  ),
3611
- kind === "withinDays" ? /* @__PURE__ */ jsx45(NumberInput, { label: "Days", min: 1, max: maxDays, step: 1, value: daysDraft ?? "", onChange: (e) => applyWithinDays(e.target.value) }) : /* @__PURE__ */ jsx45(
3772
+ kind === "withinDays" ? /* @__PURE__ */ jsx45(
3773
+ NumberInput,
3774
+ {
3775
+ label: "Days",
3776
+ min: 1,
3777
+ max: maxDays,
3778
+ step: 1,
3779
+ integer: true,
3780
+ value: daysDraft ?? "",
3781
+ onChange: (e) => applyWithinDays(e.target.value),
3782
+ onValidityChange: setDaysTextValid
3783
+ }
3784
+ ) : /* @__PURE__ */ jsx45(
3612
3785
  DatePicker,
3613
3786
  {
3614
3787
  range: true,
@@ -3627,6 +3800,33 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3627
3800
  const addAnchorRef = React24.useRef(null);
3628
3801
  const textInputRef = React24.useRef(null);
3629
3802
  const optionsRef = React24.useRef(null);
3803
+ if (process.env.NODE_ENV !== "production") {
3804
+ for (const f of fields) assertValidFieldDefinition(f);
3805
+ }
3806
+ const latest = React24.useRef({ fields, filters, onFilterChange, onFilterRemove });
3807
+ latest.current = { fields, filters, onFilterChange, onFilterRemove };
3808
+ const commit = (field, candidate) => {
3809
+ const current2 = latest.current.fields.find((f) => f.key === field.key);
3810
+ if (!current2) return { ok: false, reason: "This filter is no longer available" };
3811
+ if (isEmptyCandidate(current2, candidate)) {
3812
+ removeField(current2);
3813
+ return { ok: true };
3814
+ }
3815
+ const result = validateFilterValue(current2, candidate);
3816
+ if (result.ok) {
3817
+ latest.current.filters = { ...latest.current.filters, [current2.key]: candidate };
3818
+ latest.current.onFilterChange(current2.key, candidate);
3819
+ }
3820
+ return result;
3821
+ };
3822
+ const removeField = (field) => {
3823
+ const { filters: now, onFilterRemove: remove } = latest.current;
3824
+ if (!isFieldSet(field, now[field.key])) return;
3825
+ const next = { ...now };
3826
+ delete next[field.key];
3827
+ latest.current.filters = next;
3828
+ remove(field.key);
3829
+ };
3630
3830
  const editingField = panel && panel.kind === "edit" ? fields.find((f) => f.key === panel.key) : void 0;
3631
3831
  const startEditing = (key) => setPanel({ kind: "edit", key });
3632
3832
  const closeEditing = () => setPanel(null);
@@ -3658,7 +3858,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3658
3858
  value: value ?? "",
3659
3859
  placeholder: field.placeholder || field.label,
3660
3860
  "aria-label": field.label,
3661
- onChange: (e) => onFilterChange(field.key, e.target.value),
3861
+ onChange: (e) => commit(field, e.target.value),
3662
3862
  onKeyDown: (e) => {
3663
3863
  if (e.key === "Enter") setPanel(null);
3664
3864
  else if (e.key === "Escape") {
@@ -3677,7 +3877,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3677
3877
  "aria-label": "Remove " + field.label,
3678
3878
  onClick: (e) => {
3679
3879
  e.stopPropagation();
3680
- onFilterRemove(field.key);
3880
+ removeField(field);
3681
3881
  setPanel(null);
3682
3882
  },
3683
3883
  children: /* @__PURE__ */ jsx45("i", { className: "ph ph-x", "aria-hidden": "true" })
@@ -3690,7 +3890,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3690
3890
  const chipRef = editing ? editAnchorRef : void 0;
3691
3891
  const toggleOrClose = () => editing ? closeEditing() : startEditing(field.key);
3692
3892
  const remove = () => {
3693
- if (set) onFilterRemove(field.key);
3893
+ removeField(field);
3694
3894
  closeEditing();
3695
3895
  };
3696
3896
  const chipText = field.label + (set ? ": " + chipLabel(field, value) : "");
@@ -3720,7 +3920,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3720
3920
  "aria-selected": on,
3721
3921
  className: ["fd-opt", on ? "is-selected" : ""].filter(Boolean).join(" "),
3722
3922
  onClick: () => {
3723
- onFilterChange(field.key, o.value);
3923
+ commit(field, o.value);
3724
3924
  setPanel(null);
3725
3925
  },
3726
3926
  children: /* @__PURE__ */ jsx45("span", { className: "fd-opt-label", children: o.label })
@@ -3741,8 +3941,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3741
3941
  field,
3742
3942
  value,
3743
3943
  anchorRef: editAnchorRef,
3744
- onChange: (v) => onFilterChange(field.key, v),
3745
- onRemove: () => onFilterRemove(field.key),
3944
+ onCommit: (v) => commit(field, v),
3746
3945
  onClose: closeEditing
3747
3946
  }
3748
3947
  ) : null
@@ -3757,8 +3956,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3757
3956
  field,
3758
3957
  value,
3759
3958
  anchorRef: editAnchorRef,
3760
- onChange: (v) => onFilterChange(field.key, v),
3761
- onRemove: () => onFilterRemove(field.key),
3959
+ onCommit: (v) => commit(field, v),
3762
3960
  onClose: closeEditing
3763
3961
  }
3764
3962
  ) : null
@@ -3773,8 +3971,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3773
3971
  field,
3774
3972
  value,
3775
3973
  anchorRef: editAnchorRef,
3776
- onChange: (v) => onFilterChange(field.key, v),
3777
- onRemove: () => onFilterRemove(field.key),
3974
+ onCommit: (v) => commit(field, v),
3778
3975
  onClose: closeEditing
3779
3976
  }
3780
3977
  ) : null
@@ -13622,6 +13819,7 @@ export {
13622
13819
  useServerTable,
13623
13820
  useSessionContext,
13624
13821
  useStagedFiles,
13822
+ validateFilterValue,
13625
13823
  visibleMessages
13626
13824
  };
13627
13825
  //# sourceMappingURL=index.js.map