@flytedan/flytebot-design-system 0.31.0 → 0.33.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,
@@ -2782,70 +2824,78 @@ function NumberInput({
2782
2824
  }) {
2783
2825
  const fieldId = useFieldId(id);
2784
2826
  const errorId = fieldId + "-error";
2785
- const num = (v) => {
2786
- const s = String(v == null ? "" : v).replace(/[^0-9.-]/g, "");
2787
- if (s === "" || s === "-") return null;
2788
- const n = Number(s);
2789
- return isNaN(n) ? null : n;
2790
- };
2791
- const validationMessage = (n) => {
2792
- if (integer && !Number.isInteger(n)) return "Enter a whole number";
2793
- if (n < min) return "Must be \u2265 " + min;
2794
- if (n > max) return "Must be \u2264 " + max;
2795
- 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;
2796
2834
  };
2797
- const [text, setText] = React21.useState(value == null || value === "" ? "" : String(value));
2835
+ const [text, setText] = React21.useState(() => toText(value));
2798
2836
  const [editing, setEditing] = React21.useState(false);
2799
- const [invalidMessage, setInvalidMessage] = React21.useState(null);
2800
- const timer = React21.useRef(null);
2801
- React21.useEffect(() => {
2802
- if (!editing) {
2803
- setText(value == null || value === "" ? "" : String(value));
2804
- setInvalidMessage(null);
2805
- }
2806
- }, [value, editing]);
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);
2807
2846
  React21.useEffect(() => {
2808
- onValidityChange && onValidityChange(invalidMessage == null);
2809
- }, [invalidMessage]);
2810
- const clamp = (n) => Math.min(max, Math.max(min, n));
2811
- const fire = (n) => onChange && onChange({ target: { value: n } });
2812
- const bump = (dir, mult = 1) => {
2813
- const cur = (invalidMessage ? null : num(text)) ?? num(value) ?? (min === -Infinity ? 0 : min);
2814
- const amount = (bigStep && mult > 1 ? bigStep : step) * (bigStep ? 1 : mult);
2815
- let next = cur + dir * amount;
2816
- if (integer) next = Math.round(next);
2817
- next = clamp(next);
2818
- 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;
2819
2860
  setText(String(next));
2861
+ if (next === committedRef.current) return false;
2820
2862
  fire(next);
2863
+ return true;
2821
2864
  };
2822
- const hold = (dir) => {
2823
- bump(dir);
2824
- 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;
2825
2876
  const tick = () => {
2826
- n++;
2827
- bump(dir, n > 6 ? 4 : 1);
2828
- 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));
2829
2883
  };
2830
- timer.current = setTimeout(tick, 420);
2831
- };
2832
- const release = () => {
2833
- if (timer.current) clearTimeout(timer.current);
2884
+ holdTimer.current = setTimeout(tick, HOLD_DELAY_MS);
2834
2885
  };
2835
2886
  React21.useEffect(() => () => {
2836
- if (timer.current) clearTimeout(timer.current);
2887
+ if (holdTimer.current) clearTimeout(holdTimer.current);
2837
2888
  }, []);
2838
- const shown = editing ? text : (() => {
2839
- const n = num(text);
2840
- return n == null ? "" : format ? n.toLocaleString() : String(n);
2841
- })();
2889
+ const current2 = committedRef.current;
2890
+ const shown = editing || !valid ? text : parsed == null ? "" : format ? parsed.toLocaleString() : String(parsed);
2842
2891
  const shownError = error || invalidMessage || void 0;
2843
2892
  const box = ["fd-input", "fd-input-num", shownError ? "fd-input-error" : "", disabled ? "fd-input-disabled" : ""].filter(Boolean).join(" ");
2844
2893
  const fieldName = label || ariaLabel;
2845
2894
  const stepperName = (verb) => fieldName ? verb + " " + fieldName : verb;
2895
+ const noRange = lo > hi;
2846
2896
  return /* @__PURE__ */ jsxs36("div", { className: ["fd-field", className].filter(Boolean).join(" "), style, children: [
2847
2897
  label ? /* @__PURE__ */ jsx42(FieldLabel, { htmlFor: fieldId, required, children: label }) : null,
2848
- /* @__PURE__ */ jsxs36("div", { className: box, style: { gap: 8 }, children: [
2898
+ /* @__PURE__ */ jsxs36("div", { className: box, children: [
2849
2899
  prefix ? /* @__PURE__ */ jsx42("span", { className: "fd-input-affix", children: prefix }) : null,
2850
2900
  /* @__PURE__ */ jsx42(
2851
2901
  "input",
@@ -2858,52 +2908,54 @@ function NumberInput({
2858
2908
  value: shown,
2859
2909
  "aria-invalid": shownError ? "true" : void 0,
2860
2910
  "aria-describedby": shownError ? errorId : void 0,
2861
- onFocus: () => setEditing(true),
2911
+ onFocus: () => {
2912
+ focusedRef.current = true;
2913
+ setEditing(true);
2914
+ },
2862
2915
  onBlur: () => {
2916
+ focusedRef.current = false;
2863
2917
  setEditing(false);
2864
- if (text.trim() === "") fire(void 0);
2918
+ if (text.trim() === "" && committedRef.current != null) fire(void 0);
2865
2919
  },
2866
2920
  onChange: (e) => {
2867
2921
  const raw = e.target.value;
2868
2922
  setText(raw);
2869
- if (raw.trim() === "") {
2870
- setInvalidMessage(null);
2871
- fire(void 0);
2872
- return;
2873
- }
2874
- const n = num(raw);
2923
+ const n = parseNumberText(raw);
2875
2924
  if (n == null) {
2876
- setInvalidMessage("Enter a number");
2925
+ if (committedRef.current != null) fire(void 0);
2877
2926
  return;
2878
2927
  }
2879
- const message = validationMessage(n);
2880
- setInvalidMessage(message);
2881
- if (!message) fire(n);
2928
+ if (Number.isNaN(n) || integer && !Number.isInteger(n) || n < lo || n > hi) return;
2929
+ fire(n);
2882
2930
  },
2883
2931
  onKeyDown: (e) => {
2884
2932
  if (e.key === "ArrowUp") {
2885
2933
  e.preventDefault();
2886
- bump(1, e.shiftKey ? 2 : 1);
2934
+ bump(1, e.shiftKey ? big : step);
2887
2935
  } else if (e.key === "ArrowDown") {
2888
2936
  e.preventDefault();
2889
- bump(-1, e.shiftKey ? 2 : 1);
2937
+ bump(-1, e.shiftKey ? big : step);
2890
2938
  }
2891
2939
  }
2892
2940
  }
2893
2941
  ),
2894
2942
  suffix ? /* @__PURE__ */ jsx42("span", { className: "fd-input-affix", children: suffix }) : null,
2895
- /* @__PURE__ */ jsxs36("span", { className: "fd-row", style: { gap: 4, flex: "none" }, children: [
2943
+ /* @__PURE__ */ jsxs36("span", { className: "fd-input-steppers", children: [
2896
2944
  /* @__PURE__ */ jsx42(
2897
2945
  "button",
2898
2946
  {
2899
2947
  type: "button",
2900
2948
  className: "fd-stepbtn",
2901
2949
  "aria-label": stepperName("Decrease"),
2902
- disabled: disabled || (num(text) ?? 0) <= min,
2950
+ disabled: disabled || noRange || current2 != null && current2 <= lo,
2903
2951
  onPointerDown: () => hold(-1),
2904
2952
  onPointerUp: release,
2905
2953
  onPointerLeave: release,
2906
- 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" })
2907
2959
  }
2908
2960
  ),
2909
2961
  /* @__PURE__ */ jsx42(
@@ -2912,16 +2964,21 @@ function NumberInput({
2912
2964
  type: "button",
2913
2965
  className: "fd-stepbtn",
2914
2966
  "aria-label": stepperName("Increase"),
2915
- disabled: disabled || (num(text) ?? 0) >= max,
2967
+ disabled: disabled || noRange || current2 != null && current2 >= hi,
2916
2968
  onPointerDown: () => hold(1),
2917
2969
  onPointerUp: release,
2918
2970
  onPointerLeave: release,
2919
- 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" })
2920
2976
  }
2921
2977
  )
2922
2978
  ] })
2923
2979
  ] }),
2924
- shownError ? /* @__PURE__ */ jsxs36("span", { id: errorId, className: "fd-field-error", children: [
2980
+ /* @__PURE__ */ jsx42("span", { id: errorId, className: "fd-sr", role: "status", children: shownError ?? "" }),
2981
+ shownError ? /* @__PURE__ */ jsxs36("span", { className: "fd-field-error", "aria-hidden": "true", children: [
2925
2982
  /* @__PURE__ */ jsx42("i", { className: "ph ph-warning-circle", "aria-hidden": "true" }),
2926
2983
  shownError
2927
2984
  ] }) : help ? /* @__PURE__ */ jsx42("span", { className: "fd-field-help", children: help }) : null
@@ -3201,16 +3258,31 @@ function isFieldSet(field, value) {
3201
3258
  }
3202
3259
  }
3203
3260
  }
3261
+ var MAX_WITHIN_DAYS = 3650;
3262
+ function effectiveMaxDays(field) {
3263
+ return Math.min(field.maxDays ?? MAX_WITHIN_DAYS, MAX_WITHIN_DAYS);
3264
+ }
3265
+ function isIsoDate(v) {
3266
+ if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
3267
+ const d = /* @__PURE__ */ new Date(v + "T00:00:00Z");
3268
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
3269
+ }
3270
+ var isOption = (o) => !!o && typeof o.value === "string" && typeof o.label === "string";
3204
3271
  function validateFilterValue(field, value) {
3205
3272
  switch (field.type) {
3206
3273
  case "text":
3274
+ return typeof value === "string" ? { ok: true } : { ok: false, reason: "Enter text" };
3207
3275
  case "select":
3208
- return { ok: true };
3276
+ return field.options.some((o) => o.value === value) ? { ok: true } : { ok: false, reason: "Pick one of the options" };
3209
3277
  case "multiSelect": {
3210
3278
  const v = value;
3211
3279
  if (!v || !Array.isArray(v.values) || v.values.length === 0) {
3212
3280
  return { ok: false, reason: "Select at least one value" };
3213
3281
  }
3282
+ if (!v.values.every(isOption)) return { ok: false, reason: "Each value needs a value and a label" };
3283
+ if (field.maxValues != null && v.values.length > field.maxValues) {
3284
+ return { ok: false, reason: "Select at most " + field.maxValues + " values" };
3285
+ }
3214
3286
  if (!operatorsFor(field).includes(v.operator)) {
3215
3287
  return { ok: false, reason: "That operator isn't allowed for " + field.label };
3216
3288
  }
@@ -3225,6 +3297,7 @@ function validateFilterValue(field, value) {
3225
3297
  const hi = field.max ?? Infinity;
3226
3298
  for (const n of [v.min, v.max]) {
3227
3299
  if (n == null) continue;
3300
+ if (typeof n !== "number" || !Number.isFinite(n)) return { ok: false, reason: "Enter a number" };
3228
3301
  if (field.integer && !Number.isInteger(n)) return { ok: false, reason: "Enter a whole number" };
3229
3302
  if (n < lo) return { ok: false, reason: "Must be \u2265 " + lo };
3230
3303
  if (n > hi) return { ok: false, reason: "Must be \u2264 " + hi };
@@ -3238,46 +3311,36 @@ function validateFilterValue(field, value) {
3238
3311
  const v = value;
3239
3312
  if (!v) return { ok: false, reason: "Pick a date value" };
3240
3313
  if (v.kind === "withinDays") {
3241
- const maxDays = field.maxDays ?? 3650;
3314
+ const maxDays = effectiveMaxDays(field);
3242
3315
  if (v.days == null) return { ok: false, reason: "Enter a number of days" };
3243
3316
  if (!Number.isInteger(v.days)) return { ok: false, reason: "Enter a whole number" };
3244
3317
  if (v.days < 1) return { ok: false, reason: "Must be \u2265 1" };
3245
3318
  if (v.days > maxDays) return { ok: false, reason: "Must be \u2264 " + maxDays };
3246
3319
  return { ok: true };
3247
3320
  }
3321
+ if (v.kind !== "between") return { ok: false, reason: "Pick a date value" };
3248
3322
  if (!v.from || !v.to) return { ok: false, reason: "Pick both dates" };
3323
+ if (!isIsoDate(v.from) || !isIsoDate(v.to)) return { ok: false, reason: "Dates must be YYYY-MM-DD" };
3249
3324
  if (v.from > v.to) return { ok: false, reason: "The start date must be on or before the end date" };
3250
3325
  return { ok: true };
3251
3326
  }
3252
3327
  }
3253
3328
  }
3254
3329
  function isEmptyCandidate(field, candidate) {
3255
- switch (field.type) {
3256
- case "text":
3257
- case "select":
3258
- return false;
3259
- case "multiSelect": {
3260
- const v = candidate;
3261
- return !v || !Array.isArray(v.values) || v.values.length === 0;
3262
- }
3263
- case "range": {
3264
- const v = candidate;
3265
- return !v || v.min == null && v.max == null;
3266
- }
3267
- case "date": {
3268
- const v = candidate;
3269
- if (!v) return true;
3270
- return v.kind === "withinDays" ? v.days == null : !(v.from && v.to);
3271
- }
3272
- }
3330
+ return field.type !== "text" && field.type !== "select" && !isFieldSet(field, candidate);
3273
3331
  }
3274
- function assertValidDateField(field) {
3275
- if (process.env.NODE_ENV === "production") return;
3332
+ function assertValidFieldDefinition(field) {
3333
+ if (field.type === "multiSelect" && field.maxValues != null && (!Number.isInteger(field.maxValues) || field.maxValues < 1)) {
3334
+ throw new Error(
3335
+ 'FilterBar: multiSelect field "' + field.key + '" has an invalid maxValues (' + field.maxValues + ") \u2014 must be a whole number of at least 1."
3336
+ );
3337
+ }
3338
+ if (field.type !== "date") return;
3276
3339
  const defaultDays = field.defaultDays ?? 30;
3277
- const maxDays = field.maxDays ?? 3650;
3278
- if (!Number.isInteger(maxDays) || maxDays < 1 || maxDays > 3650) {
3340
+ const maxDays = field.maxDays ?? MAX_WITHIN_DAYS;
3341
+ if (!Number.isInteger(maxDays) || maxDays < 1 || maxDays > MAX_WITHIN_DAYS) {
3279
3342
  throw new Error(
3280
- 'FilterBar: date field "' + field.key + '" has an invalid maxDays (' + field.maxDays + ") \u2014 must be a whole number from 1 to 3650."
3343
+ 'FilterBar: date field "' + field.key + '" has an invalid maxDays (' + field.maxDays + ") \u2014 must be a whole number from 1 to " + MAX_WITHIN_DAYS + "."
3281
3344
  );
3282
3345
  }
3283
3346
  if (!Number.isInteger(defaultDays) || defaultDays < 1 || defaultDays > maxDays) {
@@ -3356,12 +3419,10 @@ function MultiSelectPanel({
3356
3419
  mountedRef.current = false;
3357
3420
  };
3358
3421
  }, []);
3359
- const commit = (nextValues, nextOperator = operator) => {
3360
- onCommit({ operator: nextOperator, values: nextValues });
3361
- };
3422
+ const commit = (nextValues, nextOperator = operator) => onCommit({ operator: nextOperator, values: nextValues });
3362
3423
  const setOperator = (op) => {
3363
3424
  setDraftOperator(op);
3364
- if (selected.length > 0) commit(selected, op);
3425
+ if (selectedRef.current.length > 0) commit(selectedRef.current, op);
3365
3426
  };
3366
3427
  const [mode2, setMode] = React24.useState("search");
3367
3428
  const [query, setQuery] = React24.useState("");
@@ -3401,9 +3462,12 @@ function MultiSelectPanel({
3401
3462
  const showError = !!field.loadOptions && asyncState.error;
3402
3463
  const hasListbox = !showLoading && !showError && options.length > 0;
3403
3464
  const isSelected = (o) => selected.some((s) => s.value === o.value);
3465
+ const atLimit = field.maxValues != null && selected.length >= field.maxValues;
3404
3466
  const toggle = (o) => {
3405
- const next = isSelected(o) ? selected.filter((s) => s.value !== o.value) : [...selected, o];
3406
- commit(next);
3467
+ const current2 = selectedRef.current;
3468
+ const on = current2.some((s) => s.value === o.value);
3469
+ if (!on && field.maxValues != null && current2.length >= field.maxValues) return;
3470
+ commit(on ? current2.filter((s) => s.value !== o.value) : [...current2, o]);
3407
3471
  };
3408
3472
  const nav = useListboxNav({ count: options.length, onSelect: (i) => toggle(options[i]) });
3409
3473
  useListboxScrollIntoView(nav.active, listRef, hasListbox);
@@ -3411,8 +3475,7 @@ function MultiSelectPanel({
3411
3475
  nav.setActive(0);
3412
3476
  }, [query, options.length]);
3413
3477
  const activeId = hasListbox && options[nav.active] ? listId + "-opt-" + nav.active : void 0;
3414
- const liveRole = showError ? "alert" : "status";
3415
- const liveMessage = showLoading ? "Loading\u2026" : showError ? "Couldn't load options." : options.length === 0 ? "No matches" : "";
3478
+ const liveMessage = showLoading ? "Loading\u2026" : showError ? "Couldn't load options." : options.length === 0 ? "No matches" : atLimit ? "Limit reached: at most " + field.maxValues + " values" : "";
3416
3479
  const [pasteText, setPasteText] = React24.useState("");
3417
3480
  const [pasteLoading, setPasteLoading] = React24.useState(false);
3418
3481
  const [unmatchedTokens, setUnmatchedTokens] = React24.useState([]);
@@ -3433,9 +3496,17 @@ function MultiSelectPanel({
3433
3496
  setUnmatchedTokens(result.unmatched);
3434
3497
  const merged = new Map(selectedRef.current.map((s) => [s.value, s]));
3435
3498
  for (const m of result.matched) merged.set(m.value, { value: m.value, label: m.label });
3436
- commit(Array.from(merged.values()), operatorRef.current);
3499
+ if (field.maxValues != null && merged.size > field.maxValues) {
3500
+ setPasteError("This filter allows at most " + field.maxValues + " values; adding this list would make " + merged.size + ".");
3501
+ return;
3502
+ }
3503
+ const applied = commit(Array.from(merged.values()), operatorRef.current);
3504
+ if (!applied.ok) {
3505
+ setPasteError(applied.reason);
3506
+ return;
3507
+ }
3437
3508
  setPasteText("");
3438
- }).catch((err) => {
3509
+ }, (err) => {
3439
3510
  if (!mountedRef.current) return;
3440
3511
  setPasteLoading(false);
3441
3512
  setPasteError(err instanceof Error ? err.message : "Could not resolve the pasted list.");
@@ -3514,14 +3585,14 @@ function MultiSelectPanel({
3514
3585
  selected.length,
3515
3586
  " matched"
3516
3587
  ] }),
3517
- /* @__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)) })
3588
+ /* @__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)) })
3518
3589
  ] }) : null
3519
3590
  ] }) : /* @__PURE__ */ jsxs39("span", { className: "fd-stack fd-gap-sm", children: [
3520
3591
  /* @__PURE__ */ jsxs39(
3521
3592
  "span",
3522
3593
  {
3523
3594
  className: "fd-row fd-gap-xs" + (liveMessage && !showError ? " fd-muted" : ""),
3524
- role: liveRole,
3595
+ role: "status",
3525
3596
  children: [
3526
3597
  showLoading ? /* @__PURE__ */ jsx45("span", { className: "fd-spinner", "aria-hidden": "true" }) : null,
3527
3598
  liveMessage ? /* @__PURE__ */ jsx45("span", { className: "fd-body-sm", children: liveMessage }) : null
@@ -3531,6 +3602,7 @@ function MultiSelectPanel({
3531
3602
  showError ? /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: () => setRetryTick((n) => n + 1), children: "Retry" }) : null,
3532
3603
  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) => {
3533
3604
  const on = isSelected(o);
3605
+ const blocked = !on && atLimit;
3534
3606
  return /* @__PURE__ */ jsxs39(
3535
3607
  "div",
3536
3608
  {
@@ -3539,6 +3611,7 @@ function MultiSelectPanel({
3539
3611
  role: "option",
3540
3612
  tabIndex: -1,
3541
3613
  "aria-selected": on,
3614
+ "aria-disabled": blocked ? "true" : void 0,
3542
3615
  className: ["fd-opt", i === nav.active ? "is-active" : "", on ? "is-selected" : ""].filter(Boolean).join(" "),
3543
3616
  onMouseEnter: () => nav.setActive(i),
3544
3617
  onClick: () => toggle(o),
@@ -3568,6 +3641,34 @@ function MultiSelectPanel({
3568
3641
  }
3569
3642
  );
3570
3643
  }
3644
+ function useTextValidity(onFlip) {
3645
+ const invalidRef = React24.useRef(/* @__PURE__ */ new Set());
3646
+ const [, rerender] = React24.useReducer((n) => n + 1, 0);
3647
+ const onFlipRef = React24.useRef(onFlip);
3648
+ onFlipRef.current = onFlip;
3649
+ const record = (name, valid) => {
3650
+ const invalid = invalidRef.current;
3651
+ if (valid !== invalid.has(name)) return false;
3652
+ if (valid) invalid.delete(name);
3653
+ else invalid.add(name);
3654
+ rerender();
3655
+ return true;
3656
+ };
3657
+ return {
3658
+ allValid: () => invalidRef.current.size === 0,
3659
+ report: (name) => (valid) => {
3660
+ if (record(name, valid)) onFlipRef.current();
3661
+ },
3662
+ /** For an input that is about to unmount: it has nothing left to show. */
3663
+ forget: (name) => {
3664
+ record(name, true);
3665
+ }
3666
+ };
3667
+ }
3668
+ function applicable(field, candidate, textValid) {
3669
+ if (!textValid || candidate === void 0) return void 0;
3670
+ return validateFilterValue(field, candidate).ok ? candidate : void 0;
3671
+ }
3571
3672
  function RangePanel({
3572
3673
  field,
3573
3674
  value,
@@ -3575,18 +3676,24 @@ function RangePanel({
3575
3676
  onCommit,
3576
3677
  onClose
3577
3678
  }) {
3578
- const [draftMin, setDraftMin] = React24.useState(value?.min);
3579
- const [draftMax, setDraftMax] = React24.useState(value?.max);
3580
- const [minTextValid, setMinTextValid] = React24.useState(true);
3581
- const [maxTextValid, setMaxTextValid] = React24.useState(true);
3679
+ const [draft, setDraft] = React24.useState({ min: value?.min, max: value?.max });
3680
+ const draftRef = React24.useRef(draft);
3681
+ const publish = () => {
3682
+ const { min, max } = draftRef.current;
3683
+ onCommit(applicable(field, min == null && max == null ? void 0 : { min, max }, validity.allValid()));
3684
+ };
3685
+ const validity = useTextValidity(publish);
3686
+ const draftMin = draft.min;
3687
+ const draftMax = draft.max;
3582
3688
  const isEmpty = draftMin == null && draftMax == null;
3583
3689
  const validation = isEmpty ? { ok: true } : validateFilterValue(field, { min: draftMin, max: draftMax });
3584
3690
  const comboError = !isEmpty && !validation.ok ? validation.reason : void 0;
3585
- const doneDisabled = !minTextValid || !maxTextValid || !!comboError;
3586
- const apply = (nextMin, nextMax) => {
3587
- setDraftMin(nextMin);
3588
- setDraftMax(nextMax);
3589
- onCommit(nextMin == null && nextMax == null ? void 0 : { min: nextMin, max: nextMax });
3691
+ const doneDisabled = !validity.allValid() || !!comboError;
3692
+ const apply = (patch) => {
3693
+ const next = { ...draftRef.current, ...patch };
3694
+ draftRef.current = next;
3695
+ setDraft(next);
3696
+ publish();
3590
3697
  };
3591
3698
  return /* @__PURE__ */ jsx45(
3592
3699
  Popover,
@@ -3610,8 +3717,8 @@ function RangePanel({
3610
3717
  step: field.step,
3611
3718
  integer: field.integer,
3612
3719
  placeholder: field.minPlaceholder,
3613
- onChange: (e) => apply(e.target.value, draftMax),
3614
- onValidityChange: setMinTextValid
3720
+ onChange: (e) => apply({ min: e.target.value }),
3721
+ onValidityChange: validity.report("min")
3615
3722
  }
3616
3723
  ),
3617
3724
  /* @__PURE__ */ jsx45(
@@ -3625,8 +3732,8 @@ function RangePanel({
3625
3732
  integer: field.integer,
3626
3733
  error: comboError,
3627
3734
  placeholder: field.maxPlaceholder,
3628
- onChange: (e) => apply(draftMin, e.target.value),
3629
- onValidityChange: setMaxTextValid
3735
+ onChange: (e) => apply({ max: e.target.value }),
3736
+ onValidityChange: validity.report("max")
3630
3737
  }
3631
3738
  )
3632
3739
  ] })
@@ -3641,31 +3748,48 @@ function DatePanel({
3641
3748
  onClose
3642
3749
  }) {
3643
3750
  const defaultDays = field.defaultDays ?? 30;
3644
- const maxDays = field.maxDays ?? 3650;
3751
+ const maxDays = effectiveMaxDays(field);
3645
3752
  const [kind, setKind] = React24.useState(value?.kind ?? "withinDays");
3646
3753
  const [daysDraft, setDaysDraft] = React24.useState(
3647
3754
  value?.kind === "withinDays" ? value.days : defaultDays
3648
3755
  );
3756
+ const daysRef = React24.useRef(daysDraft);
3649
3757
  const [between, setBetween] = React24.useState(
3650
3758
  value?.kind === "between" ? { from: value.from, to: value.to } : { from: "", to: "" }
3651
3759
  );
3760
+ const shownWithinDays = () => {
3761
+ const days = daysRef.current;
3762
+ return applicable(field, days == null ? void 0 : { kind: "withinDays", days }, validity.allValid());
3763
+ };
3764
+ const validity = useTextValidity(() => onCommit(shownWithinDays()));
3652
3765
  const applyWithinDays = (days) => {
3766
+ daysRef.current = days;
3653
3767
  setDaysDraft(days);
3654
- onCommit(days == null ? void 0 : { kind: "withinDays", days });
3768
+ onCommit(shownWithinDays());
3655
3769
  };
3656
3770
  const applyBetween = (from, to) => {
3657
3771
  setBetween({ from, to });
3658
- onCommit(from && to ? { kind: "between", from, to } : void 0);
3772
+ onCommit(applicable(field, from && to ? { kind: "between", from, to } : void 0, true));
3659
3773
  };
3660
3774
  const switchKind = (next) => {
3661
3775
  if (next === kind) return;
3662
3776
  setKind(next);
3663
- if (next === "between") setBetween({ from: "", to: "" });
3664
- else setDaysDraft(defaultDays);
3777
+ if (next === "between") {
3778
+ setBetween({ from: "", to: "" });
3779
+ validity.forget("days");
3780
+ } else {
3781
+ daysRef.current = defaultDays;
3782
+ setDaysDraft(defaultDays);
3783
+ }
3665
3784
  onCommit(void 0);
3666
3785
  };
3786
+ const doneDisabled = kind === "withinDays" && !validity.allValid();
3667
3787
  const confirm = () => {
3668
- if (kind === "withinDays" && daysDraft != null) onCommit({ kind: "withinDays", days: daysDraft });
3788
+ if (doneDisabled) return;
3789
+ if (kind === "withinDays") {
3790
+ const shown = shownWithinDays();
3791
+ if (shown) onCommit(shown);
3792
+ }
3669
3793
  onClose();
3670
3794
  };
3671
3795
  return /* @__PURE__ */ jsx45(
@@ -3678,7 +3802,7 @@ function DatePanel({
3678
3802
  offset: 6,
3679
3803
  width: PANEL_WIDTH.date,
3680
3804
  label: field.label,
3681
- footer: /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: confirm, children: "Done" }),
3805
+ footer: /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: confirm, disabled: doneDisabled, children: "Done" }),
3682
3806
  children: /* @__PURE__ */ jsxs39("span", { className: "fd-stack fd-gap-sm", children: [
3683
3807
  /* @__PURE__ */ jsx45(
3684
3808
  SegmentedControl,
@@ -3692,7 +3816,19 @@ function DatePanel({
3692
3816
  onChange: (k) => switchKind(k)
3693
3817
  }
3694
3818
  ),
3695
- kind === "withinDays" ? /* @__PURE__ */ jsx45(NumberInput, { label: "Days", min: 1, max: maxDays, step: 1, integer: true, value: daysDraft ?? "", onChange: (e) => applyWithinDays(e.target.value) }) : /* @__PURE__ */ jsx45(
3819
+ kind === "withinDays" ? /* @__PURE__ */ jsx45(
3820
+ NumberInput,
3821
+ {
3822
+ label: "Days",
3823
+ min: 1,
3824
+ max: maxDays,
3825
+ step: 1,
3826
+ integer: true,
3827
+ value: daysDraft ?? "",
3828
+ onChange: (e) => applyWithinDays(e.target.value),
3829
+ onValidityChange: validity.report("days")
3830
+ }
3831
+ ) : /* @__PURE__ */ jsx45(
3696
3832
  DatePicker,
3697
3833
  {
3698
3834
  range: true,
@@ -3712,19 +3848,38 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3712
3848
  const textInputRef = React24.useRef(null);
3713
3849
  const optionsRef = React24.useRef(null);
3714
3850
  if (process.env.NODE_ENV !== "production") {
3715
- for (const f of fields) if (f.type === "date") assertValidDateField(f);
3851
+ for (const f of fields) assertValidFieldDefinition(f);
3716
3852
  }
3853
+ const latest = React24.useRef({ fields, filters, onFilterChange, onFilterRemove });
3854
+ const filtersProp = React24.useRef(filters);
3855
+ if (filters !== filtersProp.current) {
3856
+ filtersProp.current = filters;
3857
+ latest.current.filters = filters;
3858
+ }
3859
+ latest.current.fields = fields;
3860
+ latest.current.onFilterChange = onFilterChange;
3861
+ latest.current.onFilterRemove = onFilterRemove;
3717
3862
  const commit = (field, candidate) => {
3718
- if (isEmptyCandidate(field, candidate)) {
3719
- removeField(field);
3863
+ const current2 = latest.current.fields.find((f) => f.key === field.key);
3864
+ if (!current2) return { ok: false, reason: "This filter is no longer available" };
3865
+ if (isEmptyCandidate(current2, candidate)) {
3866
+ removeField(current2);
3720
3867
  return { ok: true };
3721
3868
  }
3722
- const result = validateFilterValue(field, candidate);
3723
- if (result.ok) onFilterChange(field.key, candidate);
3869
+ const result = validateFilterValue(current2, candidate);
3870
+ if (result.ok) {
3871
+ latest.current.filters = { ...latest.current.filters, [current2.key]: candidate };
3872
+ latest.current.onFilterChange(current2.key, candidate);
3873
+ }
3724
3874
  return result;
3725
3875
  };
3726
3876
  const removeField = (field) => {
3727
- if (isFieldSet(field, filters[field.key])) onFilterRemove(field.key);
3877
+ const { filters: now, onFilterRemove: remove } = latest.current;
3878
+ if (!isFieldSet(field, now[field.key])) return;
3879
+ const next = { ...now };
3880
+ delete next[field.key];
3881
+ latest.current.filters = next;
3882
+ remove(field.key);
3728
3883
  };
3729
3884
  const editingField = panel && panel.kind === "edit" ? fields.find((f) => f.key === panel.key) : void 0;
3730
3885
  const startEditing = (key) => setPanel({ kind: "edit", key });
@@ -13718,6 +13873,7 @@ export {
13718
13873
  useServerTable,
13719
13874
  useSessionContext,
13720
13875
  useStagedFiles,
13876
+ validateFilterValue,
13721
13877
  visibleMessages
13722
13878
  };
13723
13879
  //# sourceMappingURL=index.js.map