@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.cjs CHANGED
@@ -233,6 +233,7 @@ __export(index_exports, {
233
233
  useServerTable: () => useServerTable,
234
234
  useSessionContext: () => useSessionContext,
235
235
  useStagedFiles: () => useStagedFiles,
236
+ validateFilterValue: () => validateFilterValue,
236
237
  visibleMessages: () => visibleMessages
237
238
  });
238
239
  module.exports = __toCommonJS(index_exports);
@@ -2996,6 +2997,48 @@ function useTriggerLabelling(label, id) {
2996
2997
 
2997
2998
  // src/components/forms/NumberInput.tsx
2998
2999
  var import_jsx_runtime42 = require("react/jsx-runtime");
3000
+ var HOLD_DELAY_MS = 400;
3001
+ var HOLD_BIG_AFTER = 6;
3002
+ var HOLD_FAST_AFTER = 12;
3003
+ var holdInterval = (repeats) => repeats > HOLD_FAST_AFTER ? 40 : repeats > HOLD_BIG_AFTER ? 70 : 120;
3004
+ function decimalPlaces(n) {
3005
+ if (!Number.isFinite(n)) return 0;
3006
+ const [mantissa, exp] = String(n).toLowerCase().split("e");
3007
+ const frac = (mantissa.split(".")[1] || "").length;
3008
+ return Math.max(0, frac - (exp ? Number(exp) : 0));
3009
+ }
3010
+ function parseNumberText(text) {
3011
+ const s = text.replace(/,/g, "").trim();
3012
+ if (s === "") return null;
3013
+ if (!/^-?(\d+\.?\d*|\.\d+)$/.test(s)) return NaN;
3014
+ return Number(s);
3015
+ }
3016
+ function allowedRange(min, max, integer) {
3017
+ return integer ? { lo: Math.ceil(min), hi: Math.floor(max) } : { lo: min, hi: max };
3018
+ }
3019
+ function stepNumber({ from, direction, amount, step, min, max, integer }) {
3020
+ const { lo, hi } = allowedRange(min, max, integer);
3021
+ if (lo > hi) return null;
3022
+ const base = from ?? 0;
3023
+ const anchor = Number.isFinite(lo) ? lo : 0;
3024
+ const unit = step > 0 ? step : 1;
3025
+ const travel = amount > 0 ? amount : unit;
3026
+ const magnitude = Math.max(Math.abs(base), Math.abs(anchor), travel, unit, 1);
3027
+ const maxPlaces = Math.max(0, Math.floor(Math.log10(Number.MAX_SAFE_INTEGER / (4 * magnitude))));
3028
+ const places = Math.min(maxPlaces, Math.max(decimalPlaces(base), decimalPlaces(anchor), decimalPlaces(travel), decimalPlaces(unit)));
3029
+ const scale = 10 ** places;
3030
+ const b = Math.round(base * scale);
3031
+ const a = Math.round(anchor * scale);
3032
+ const s = Math.round(unit * scale);
3033
+ const t = Math.round(travel * scale);
3034
+ const target = b + direction * t;
3035
+ let k = direction > 0 ? Math.floor((target - a) / s) : Math.ceil((target - a) / s);
3036
+ if (direction > 0 && a + k * s <= b) k = Math.floor((b - a) / s) + 1;
3037
+ if (direction < 0 && a + k * s >= b) k = Math.ceil((b - a) / s) - 1;
3038
+ let next = (a + k * s) / scale;
3039
+ if (integer && !Number.isInteger(next)) next = direction > 0 ? Math.ceil(next) : Math.floor(next);
3040
+ return Math.min(hi, Math.max(lo, next));
3041
+ }
2999
3042
  function NumberInput({
3000
3043
  label,
3001
3044
  help,
@@ -3008,8 +3051,10 @@ function NumberInput({
3008
3051
  max = Infinity,
3009
3052
  step = 1,
3010
3053
  bigStep,
3054
+ integer = false,
3011
3055
  value,
3012
3056
  onChange,
3057
+ onValidityChange,
3013
3058
  format = true,
3014
3059
  className = "",
3015
3060
  style,
@@ -3019,65 +3064,78 @@ function NumberInput({
3019
3064
  }) {
3020
3065
  const fieldId = useFieldId(id);
3021
3066
  const errorId = fieldId + "-error";
3022
- const num = (v) => {
3023
- const s = String(v == null ? "" : v).replace(/[^0-9.-]/g, "");
3024
- if (s === "" || s === "-") return null;
3025
- const n = Number(s);
3026
- return isNaN(n) ? null : n;
3027
- };
3028
- const requiresInteger = Number.isInteger(step);
3029
- const validationMessage = (n) => {
3030
- if (requiresInteger && !Number.isInteger(n)) return "Enter a whole number";
3031
- if (n < min) return "Must be \u2265 " + min;
3032
- if (n > max) return "Must be \u2264 " + max;
3033
- return null;
3067
+ const { lo, hi } = allowedRange(min, max, integer);
3068
+ const big = bigStep ?? Number((step * 10).toFixed(decimalPlaces(step)));
3069
+ const toText = (v) => v == null || v === "" ? "" : String(v);
3070
+ const toNumber = (v) => {
3071
+ if (v == null || v === "") return null;
3072
+ const n = typeof v === "number" ? v : parseNumberText(v);
3073
+ return n == null || Number.isNaN(n) ? null : n;
3034
3074
  };
3035
- const [text, setText] = React21.useState(value == null || value === "" ? "" : String(value));
3075
+ const [text, setText] = React21.useState(() => toText(value));
3036
3076
  const [editing, setEditing] = React21.useState(false);
3037
- const [invalidMessage, setInvalidMessage] = React21.useState(null);
3038
- const timer = React21.useRef(null);
3077
+ const parsed = parseNumberText(text);
3078
+ 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;
3079
+ const valid = invalidMessage == null;
3080
+ const committedRef = React21.useRef(toNumber(value));
3081
+ const onChangeRef = React21.useRef(onChange);
3082
+ onChangeRef.current = onChange;
3083
+ const onValidityChangeRef = React21.useRef(onValidityChange);
3084
+ onValidityChangeRef.current = onValidityChange;
3085
+ const focusedRef = React21.useRef(false);
3039
3086
  React21.useEffect(() => {
3040
- if (!editing) {
3041
- setText(value == null || value === "" ? "" : String(value));
3042
- setInvalidMessage(null);
3043
- }
3044
- }, [value, editing]);
3045
- const clamp = (n) => Math.min(max, Math.max(min, n));
3046
- const fire = (n) => onChange && onChange({ target: { value: n } });
3047
- const bump = (dir, mult = 1) => {
3048
- const cur = num(text) ?? num(value) ?? (min === -Infinity ? 0 : min);
3049
- const next = clamp(cur + dir * (bigStep && mult > 1 ? bigStep : step) * (bigStep ? 1 : mult));
3050
- setInvalidMessage(null);
3087
+ committedRef.current = toNumber(value);
3088
+ if (!focusedRef.current) setText(toText(value));
3089
+ }, [value]);
3090
+ React21.useLayoutEffect(() => {
3091
+ onValidityChangeRef.current?.(valid);
3092
+ }, [valid]);
3093
+ const fire = (n) => {
3094
+ committedRef.current = n ?? null;
3095
+ onChangeRef.current?.({ target: { value: n } });
3096
+ };
3097
+ const bump = (direction, amount) => {
3098
+ const next = stepNumber({ from: committedRef.current, direction, amount, step, min, max, integer });
3099
+ if (next == null) return false;
3051
3100
  setText(String(next));
3101
+ if (next === committedRef.current) return false;
3052
3102
  fire(next);
3103
+ return true;
3053
3104
  };
3054
- const hold = (dir) => {
3055
- bump(dir);
3056
- let n = 0;
3105
+ const bumpRef = React21.useRef(bump);
3106
+ bumpRef.current = bump;
3107
+ const holdTimer = React21.useRef(null);
3108
+ const release = () => {
3109
+ if (holdTimer.current) clearTimeout(holdTimer.current);
3110
+ holdTimer.current = null;
3111
+ };
3112
+ const hold = (direction) => {
3113
+ release();
3114
+ bump(direction, step);
3115
+ let repeats = 0;
3057
3116
  const tick = () => {
3058
- n++;
3059
- bump(dir, n > 6 ? 4 : 1);
3060
- timer.current = setTimeout(tick, n > 12 ? 40 : n > 6 ? 70 : 120);
3117
+ repeats++;
3118
+ if (!bumpRef.current(direction, repeats > HOLD_BIG_AFTER ? big : step)) {
3119
+ release();
3120
+ return;
3121
+ }
3122
+ holdTimer.current = setTimeout(tick, holdInterval(repeats));
3061
3123
  };
3062
- timer.current = setTimeout(tick, 420);
3063
- };
3064
- const release = () => {
3065
- if (timer.current) clearTimeout(timer.current);
3124
+ holdTimer.current = setTimeout(tick, HOLD_DELAY_MS);
3066
3125
  };
3067
3126
  React21.useEffect(() => () => {
3068
- if (timer.current) clearTimeout(timer.current);
3127
+ if (holdTimer.current) clearTimeout(holdTimer.current);
3069
3128
  }, []);
3070
- const shown = editing ? text : (() => {
3071
- const n = num(text);
3072
- return n == null ? "" : format ? n.toLocaleString() : String(n);
3073
- })();
3129
+ const current2 = committedRef.current;
3130
+ const shown = editing || !valid ? text : parsed == null ? "" : format ? parsed.toLocaleString() : String(parsed);
3074
3131
  const shownError = error || invalidMessage || void 0;
3075
3132
  const box = ["fd-input", "fd-input-num", shownError ? "fd-input-error" : "", disabled ? "fd-input-disabled" : ""].filter(Boolean).join(" ");
3076
3133
  const fieldName = label || ariaLabel;
3077
3134
  const stepperName = (verb) => fieldName ? verb + " " + fieldName : verb;
3135
+ const noRange = lo > hi;
3078
3136
  return /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("div", { className: ["fd-field", className].filter(Boolean).join(" "), style, children: [
3079
3137
  label ? /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(FieldLabel, { htmlFor: fieldId, required, children: label }) : null,
3080
- /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("div", { className: box, style: { gap: 8 }, children: [
3138
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("div", { className: box, children: [
3081
3139
  prefix ? /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("span", { className: "fd-input-affix", children: prefix }) : null,
3082
3140
  /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(
3083
3141
  "input",
@@ -3090,52 +3148,54 @@ function NumberInput({
3090
3148
  value: shown,
3091
3149
  "aria-invalid": shownError ? "true" : void 0,
3092
3150
  "aria-describedby": shownError ? errorId : void 0,
3093
- onFocus: () => setEditing(true),
3151
+ onFocus: () => {
3152
+ focusedRef.current = true;
3153
+ setEditing(true);
3154
+ },
3094
3155
  onBlur: () => {
3156
+ focusedRef.current = false;
3095
3157
  setEditing(false);
3096
- if (text.trim() === "") fire(void 0);
3158
+ if (text.trim() === "" && committedRef.current != null) fire(void 0);
3097
3159
  },
3098
3160
  onChange: (e) => {
3099
3161
  const raw = e.target.value;
3100
3162
  setText(raw);
3101
- if (raw.trim() === "") {
3102
- setInvalidMessage(null);
3103
- fire(void 0);
3104
- return;
3105
- }
3106
- const n = num(raw);
3163
+ const n = parseNumberText(raw);
3107
3164
  if (n == null) {
3108
- setInvalidMessage("Enter a number");
3165
+ if (committedRef.current != null) fire(void 0);
3109
3166
  return;
3110
3167
  }
3111
- const message = validationMessage(n);
3112
- setInvalidMessage(message);
3113
- if (!message) fire(n);
3168
+ if (Number.isNaN(n) || integer && !Number.isInteger(n) || n < lo || n > hi) return;
3169
+ fire(n);
3114
3170
  },
3115
3171
  onKeyDown: (e) => {
3116
3172
  if (e.key === "ArrowUp") {
3117
3173
  e.preventDefault();
3118
- bump(1, e.shiftKey ? 2 : 1);
3174
+ bump(1, e.shiftKey ? big : step);
3119
3175
  } else if (e.key === "ArrowDown") {
3120
3176
  e.preventDefault();
3121
- bump(-1, e.shiftKey ? 2 : 1);
3177
+ bump(-1, e.shiftKey ? big : step);
3122
3178
  }
3123
3179
  }
3124
3180
  }
3125
3181
  ),
3126
3182
  suffix ? /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("span", { className: "fd-input-affix", children: suffix }) : null,
3127
- /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("span", { className: "fd-row", style: { gap: 4, flex: "none" }, children: [
3183
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("span", { className: "fd-input-steppers", children: [
3128
3184
  /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(
3129
3185
  "button",
3130
3186
  {
3131
3187
  type: "button",
3132
3188
  className: "fd-stepbtn",
3133
3189
  "aria-label": stepperName("Decrease"),
3134
- disabled: disabled || (num(text) ?? 0) <= min,
3190
+ disabled: disabled || noRange || current2 != null && current2 <= lo,
3135
3191
  onPointerDown: () => hold(-1),
3136
3192
  onPointerUp: release,
3137
3193
  onPointerLeave: release,
3138
- children: /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("i", { className: "ph ph-minus" })
3194
+ onPointerCancel: release,
3195
+ onClick: (e) => {
3196
+ if (e.detail === 0) bump(-1, step);
3197
+ },
3198
+ children: /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("i", { className: "ph ph-minus", "aria-hidden": "true" })
3139
3199
  }
3140
3200
  ),
3141
3201
  /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(
@@ -3144,11 +3204,15 @@ function NumberInput({
3144
3204
  type: "button",
3145
3205
  className: "fd-stepbtn",
3146
3206
  "aria-label": stepperName("Increase"),
3147
- disabled: disabled || (num(text) ?? 0) >= max,
3207
+ disabled: disabled || noRange || current2 != null && current2 >= hi,
3148
3208
  onPointerDown: () => hold(1),
3149
3209
  onPointerUp: release,
3150
3210
  onPointerLeave: release,
3151
- children: /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("i", { className: "ph ph-plus" })
3211
+ onPointerCancel: release,
3212
+ onClick: (e) => {
3213
+ if (e.detail === 0) bump(1, step);
3214
+ },
3215
+ children: /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("i", { className: "ph ph-plus", "aria-hidden": "true" })
3152
3216
  }
3153
3217
  )
3154
3218
  ] })
@@ -3433,6 +3497,97 @@ function isFieldSet(field, value) {
3433
3497
  }
3434
3498
  }
3435
3499
  }
3500
+ var MAX_WITHIN_DAYS = 3650;
3501
+ function effectiveMaxDays(field) {
3502
+ return Math.min(field.maxDays ?? MAX_WITHIN_DAYS, MAX_WITHIN_DAYS);
3503
+ }
3504
+ function isIsoDate(v) {
3505
+ if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
3506
+ const d = /* @__PURE__ */ new Date(v + "T00:00:00Z");
3507
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
3508
+ }
3509
+ var isOption = (o) => !!o && typeof o.value === "string" && typeof o.label === "string";
3510
+ function validateFilterValue(field, value) {
3511
+ switch (field.type) {
3512
+ case "text":
3513
+ return typeof value === "string" ? { ok: true } : { ok: false, reason: "Enter text" };
3514
+ case "select":
3515
+ return field.options.some((o) => o.value === value) ? { ok: true } : { ok: false, reason: "Pick one of the options" };
3516
+ case "multiSelect": {
3517
+ const v = value;
3518
+ if (!v || !Array.isArray(v.values) || v.values.length === 0) {
3519
+ return { ok: false, reason: "Select at least one value" };
3520
+ }
3521
+ if (!v.values.every(isOption)) return { ok: false, reason: "Each value needs a value and a label" };
3522
+ if (field.maxValues != null && v.values.length > field.maxValues) {
3523
+ return { ok: false, reason: "Select at most " + field.maxValues + " values" };
3524
+ }
3525
+ if (!operatorsFor(field).includes(v.operator)) {
3526
+ return { ok: false, reason: "That operator isn't allowed for " + field.label };
3527
+ }
3528
+ return { ok: true };
3529
+ }
3530
+ case "range": {
3531
+ const v = value;
3532
+ if (!v || v.min == null && v.max == null) {
3533
+ return { ok: false, reason: "Enter at least one bound" };
3534
+ }
3535
+ const lo = field.min ?? -Infinity;
3536
+ const hi = field.max ?? Infinity;
3537
+ for (const n of [v.min, v.max]) {
3538
+ if (n == null) continue;
3539
+ if (typeof n !== "number" || !Number.isFinite(n)) return { ok: false, reason: "Enter a number" };
3540
+ if (field.integer && !Number.isInteger(n)) return { ok: false, reason: "Enter a whole number" };
3541
+ if (n < lo) return { ok: false, reason: "Must be \u2265 " + lo };
3542
+ if (n > hi) return { ok: false, reason: "Must be \u2264 " + hi };
3543
+ }
3544
+ if (v.min != null && v.max != null && v.min > v.max) {
3545
+ return { ok: false, reason: "Must be \u2265 " + v.min };
3546
+ }
3547
+ return { ok: true };
3548
+ }
3549
+ case "date": {
3550
+ const v = value;
3551
+ if (!v) return { ok: false, reason: "Pick a date value" };
3552
+ if (v.kind === "withinDays") {
3553
+ const maxDays = effectiveMaxDays(field);
3554
+ if (v.days == null) return { ok: false, reason: "Enter a number of days" };
3555
+ if (!Number.isInteger(v.days)) return { ok: false, reason: "Enter a whole number" };
3556
+ if (v.days < 1) return { ok: false, reason: "Must be \u2265 1" };
3557
+ if (v.days > maxDays) return { ok: false, reason: "Must be \u2264 " + maxDays };
3558
+ return { ok: true };
3559
+ }
3560
+ if (v.kind !== "between") return { ok: false, reason: "Pick a date value" };
3561
+ if (!v.from || !v.to) return { ok: false, reason: "Pick both dates" };
3562
+ if (!isIsoDate(v.from) || !isIsoDate(v.to)) return { ok: false, reason: "Dates must be YYYY-MM-DD" };
3563
+ if (v.from > v.to) return { ok: false, reason: "The start date must be on or before the end date" };
3564
+ return { ok: true };
3565
+ }
3566
+ }
3567
+ }
3568
+ function isEmptyCandidate(field, candidate) {
3569
+ return field.type !== "text" && field.type !== "select" && !isFieldSet(field, candidate);
3570
+ }
3571
+ function assertValidFieldDefinition(field) {
3572
+ if (field.type === "multiSelect" && field.maxValues != null && (!Number.isInteger(field.maxValues) || field.maxValues < 1)) {
3573
+ throw new Error(
3574
+ 'FilterBar: multiSelect field "' + field.key + '" has an invalid maxValues (' + field.maxValues + ") \u2014 must be a whole number of at least 1."
3575
+ );
3576
+ }
3577
+ if (field.type !== "date") return;
3578
+ const defaultDays = field.defaultDays ?? 30;
3579
+ const maxDays = field.maxDays ?? MAX_WITHIN_DAYS;
3580
+ if (!Number.isInteger(maxDays) || maxDays < 1 || maxDays > MAX_WITHIN_DAYS) {
3581
+ throw new Error(
3582
+ 'FilterBar: date field "' + field.key + '" has an invalid maxDays (' + field.maxDays + ") \u2014 must be a whole number from 1 to " + MAX_WITHIN_DAYS + "."
3583
+ );
3584
+ }
3585
+ if (!Number.isInteger(defaultDays) || defaultDays < 1 || defaultDays > maxDays) {
3586
+ throw new Error(
3587
+ 'FilterBar: date field "' + field.key + '" has an invalid defaultDays (' + field.defaultDays + ") \u2014 must be a whole number from 1 to maxDays (" + maxDays + ")."
3588
+ );
3589
+ }
3590
+ }
3436
3591
  function chipLabel(field, value) {
3437
3592
  switch (field.type) {
3438
3593
  case "text":
@@ -3485,8 +3640,7 @@ function MultiSelectPanel({
3485
3640
  field,
3486
3641
  value,
3487
3642
  anchorRef,
3488
- onChange,
3489
- onRemove,
3643
+ onCommit,
3490
3644
  onClose
3491
3645
  }) {
3492
3646
  const allowedOps = operatorsFor(field);
@@ -3495,13 +3649,21 @@ function MultiSelectPanel({
3495
3649
  const selected = value?.values ?? [];
3496
3650
  const selectedRef = React24.useRef(selected);
3497
3651
  selectedRef.current = selected;
3652
+ const operatorRef = React24.useRef(operator);
3653
+ operatorRef.current = operator;
3654
+ const mountedRef = React24.useRef(true);
3655
+ React24.useEffect(() => {
3656
+ mountedRef.current = true;
3657
+ return () => {
3658
+ mountedRef.current = false;
3659
+ };
3660
+ }, []);
3498
3661
  const commit = (nextValues, nextOperator = operator) => {
3499
- if (nextValues.length === 0) onRemove();
3500
- else onChange({ operator: nextOperator, values: nextValues });
3662
+ onCommit({ operator: nextOperator, values: nextValues });
3501
3663
  };
3502
3664
  const setOperator = (op) => {
3503
3665
  setDraftOperator(op);
3504
- if (selected.length > 0) commit(selected, op);
3666
+ if (selectedRef.current.length > 0) commit(selectedRef.current, op);
3505
3667
  };
3506
3668
  const [mode2, setMode] = React24.useState("search");
3507
3669
  const [query, setQuery] = React24.useState("");
@@ -3541,9 +3703,12 @@ function MultiSelectPanel({
3541
3703
  const showError = !!field.loadOptions && asyncState.error;
3542
3704
  const hasListbox = !showLoading && !showError && options.length > 0;
3543
3705
  const isSelected = (o) => selected.some((s) => s.value === o.value);
3706
+ const atLimit = field.maxValues != null && selected.length >= field.maxValues;
3544
3707
  const toggle = (o) => {
3545
- const next = isSelected(o) ? selected.filter((s) => s.value !== o.value) : [...selected, o];
3546
- commit(next);
3708
+ const current2 = selectedRef.current;
3709
+ const on = current2.some((s) => s.value === o.value);
3710
+ if (!on && field.maxValues != null && current2.length >= field.maxValues) return;
3711
+ commit(on ? current2.filter((s) => s.value !== o.value) : [...current2, o]);
3547
3712
  };
3548
3713
  const nav = useListboxNav({ count: options.length, onSelect: (i) => toggle(options[i]) });
3549
3714
  useListboxScrollIntoView(nav.active, listRef, hasListbox);
@@ -3551,8 +3716,7 @@ function MultiSelectPanel({
3551
3716
  nav.setActive(0);
3552
3717
  }, [query, options.length]);
3553
3718
  const activeId = hasListbox && options[nav.active] ? listId + "-opt-" + nav.active : void 0;
3554
- const liveRole = showError ? "alert" : "status";
3555
- const liveMessage = showLoading ? "Loading\u2026" : showError ? "Couldn't load options." : options.length === 0 ? "No matches" : "";
3719
+ const liveMessage = showLoading ? "Loading\u2026" : showError ? "Couldn't load options." : options.length === 0 ? "No matches" : atLimit ? "Limit reached: at most " + field.maxValues + " values" : "";
3556
3720
  const [pasteText, setPasteText] = React24.useState("");
3557
3721
  const [pasteLoading, setPasteLoading] = React24.useState(false);
3558
3722
  const [unmatchedTokens, setUnmatchedTokens] = React24.useState([]);
@@ -3568,13 +3732,19 @@ function MultiSelectPanel({
3568
3732
  setPasteLoading(true);
3569
3733
  setPasteError(null);
3570
3734
  field.resolve(tokens).then((result) => {
3735
+ if (!mountedRef.current) return;
3571
3736
  setPasteLoading(false);
3572
3737
  setUnmatchedTokens(result.unmatched);
3573
3738
  const merged = new Map(selectedRef.current.map((s) => [s.value, s]));
3574
3739
  for (const m of result.matched) merged.set(m.value, { value: m.value, label: m.label });
3575
- commit(Array.from(merged.values()));
3740
+ if (field.maxValues != null && merged.size > field.maxValues) {
3741
+ setPasteError("This filter allows at most " + field.maxValues + " values; adding this list would make " + merged.size + ".");
3742
+ return;
3743
+ }
3744
+ commit(Array.from(merged.values()), operatorRef.current);
3576
3745
  setPasteText("");
3577
- }).catch((err) => {
3746
+ }, (err) => {
3747
+ if (!mountedRef.current) return;
3578
3748
  setPasteLoading(false);
3579
3749
  setPasteError(err instanceof Error ? err.message : "Could not resolve the pasted list.");
3580
3750
  });
@@ -3652,14 +3822,14 @@ function MultiSelectPanel({
3652
3822
  selected.length,
3653
3823
  " matched"
3654
3824
  ] }),
3655
- /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "fd-row fd-wrap fd-gap-xs", children: selected.map((v) => /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Tag, { onRemove: () => commit(selected.filter((s) => s.value !== v.value)), children: v.label }, v.value)) })
3825
+ /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "fd-row fd-wrap fd-gap-xs", children: selected.map((v) => /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Tag, { onRemove: () => commit(selectedRef.current.filter((s) => s.value !== v.value)), children: v.label }, v.value)) })
3656
3826
  ] }) : null
3657
3827
  ] }) : /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("span", { className: "fd-stack fd-gap-sm", children: [
3658
3828
  /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)(
3659
3829
  "span",
3660
3830
  {
3661
3831
  className: "fd-row fd-gap-xs" + (liveMessage && !showError ? " fd-muted" : ""),
3662
- role: liveRole,
3832
+ role: "status",
3663
3833
  children: [
3664
3834
  showLoading ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "fd-spinner", "aria-hidden": "true" }) : null,
3665
3835
  liveMessage ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "fd-body-sm", children: liveMessage }) : null
@@ -3669,6 +3839,7 @@ function MultiSelectPanel({
3669
3839
  showError ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: () => setRetryTick((n) => n + 1), children: "Retry" }) : null,
3670
3840
  hasListbox ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { id: listId, role: "listbox", "aria-label": field.label, "aria-multiselectable": "true", ref: listRef, className: "fd-pop-list", children: options.map((o, i) => {
3671
3841
  const on = isSelected(o);
3842
+ const blocked = !on && atLimit;
3672
3843
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)(
3673
3844
  "div",
3674
3845
  {
@@ -3677,6 +3848,7 @@ function MultiSelectPanel({
3677
3848
  role: "option",
3678
3849
  tabIndex: -1,
3679
3850
  "aria-selected": on,
3851
+ "aria-disabled": blocked ? "true" : void 0,
3680
3852
  className: ["fd-opt", i === nav.active ? "is-active" : "", on ? "is-selected" : ""].filter(Boolean).join(" "),
3681
3853
  onMouseEnter: () => nav.setActive(i),
3682
3854
  onClick: () => toggle(o),
@@ -3710,22 +3882,24 @@ function RangePanel({
3710
3882
  field,
3711
3883
  value,
3712
3884
  anchorRef,
3713
- onChange,
3714
- onRemove,
3885
+ onCommit,
3715
3886
  onClose
3716
3887
  }) {
3717
- const [draftMin, setDraftMin] = React24.useState(value?.min);
3718
- const [draftMax, setDraftMax] = React24.useState(value?.max);
3719
- const invalid = draftMin != null && draftMax != null && draftMin > draftMax;
3720
- const apply = (nextMin, nextMax) => {
3721
- setDraftMin(nextMin);
3722
- setDraftMax(nextMax);
3723
- if (nextMin == null && nextMax == null) {
3724
- onRemove();
3725
- return;
3726
- }
3727
- if (nextMin != null && nextMax != null && nextMin > nextMax) return;
3728
- onChange({ min: nextMin, max: nextMax });
3888
+ const [draft, setDraft] = React24.useState({ min: value?.min, max: value?.max });
3889
+ const draftRef = React24.useRef(draft);
3890
+ const [minTextValid, setMinTextValid] = React24.useState(true);
3891
+ const [maxTextValid, setMaxTextValid] = React24.useState(true);
3892
+ const draftMin = draft.min;
3893
+ const draftMax = draft.max;
3894
+ const isEmpty = draftMin == null && draftMax == null;
3895
+ const validation = isEmpty ? { ok: true } : validateFilterValue(field, { min: draftMin, max: draftMax });
3896
+ const comboError = !isEmpty && !validation.ok ? validation.reason : void 0;
3897
+ const doneDisabled = !minTextValid || !maxTextValid || !!comboError;
3898
+ const apply = (patch) => {
3899
+ const next = { ...draftRef.current, ...patch };
3900
+ draftRef.current = next;
3901
+ setDraft(next);
3902
+ onCommit(next.min == null && next.max == null ? void 0 : next);
3729
3903
  };
3730
3904
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3731
3905
  Popover,
@@ -3737,7 +3911,7 @@ function RangePanel({
3737
3911
  offset: 6,
3738
3912
  width: PANEL_WIDTH.range,
3739
3913
  label: field.label,
3740
- footer: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: onClose, disabled: invalid, children: "Done" }),
3914
+ footer: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: onClose, disabled: doneDisabled, children: "Done" }),
3741
3915
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("span", { className: "fd-stack fd-gap-sm", children: [
3742
3916
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3743
3917
  NumberInput,
@@ -3747,8 +3921,10 @@ function RangePanel({
3747
3921
  min: field.min,
3748
3922
  max: field.max,
3749
3923
  step: field.step,
3924
+ integer: field.integer,
3750
3925
  placeholder: field.minPlaceholder,
3751
- onChange: (e) => apply(e.target.value, draftMax)
3926
+ onChange: (e) => apply({ min: e.target.value }),
3927
+ onValidityChange: setMinTextValid
3752
3928
  }
3753
3929
  ),
3754
3930
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
@@ -3759,9 +3935,11 @@ function RangePanel({
3759
3935
  min: field.min,
3760
3936
  max: field.max,
3761
3937
  step: field.step,
3762
- error: invalid ? "Must be \u2265 " + draftMin : void 0,
3938
+ integer: field.integer,
3939
+ error: comboError,
3763
3940
  placeholder: field.maxPlaceholder,
3764
- onChange: (e) => apply(draftMin, e.target.value)
3941
+ onChange: (e) => apply({ max: e.target.value }),
3942
+ onValidityChange: setMaxTextValid
3765
3943
  }
3766
3944
  )
3767
3945
  ] })
@@ -3772,12 +3950,12 @@ function DatePanel({
3772
3950
  field,
3773
3951
  value,
3774
3952
  anchorRef,
3775
- onChange,
3776
- onRemove,
3953
+ onCommit,
3777
3954
  onClose
3778
3955
  }) {
3779
3956
  const defaultDays = field.defaultDays ?? 30;
3780
- const maxDays = field.maxDays ?? 3650;
3957
+ const maxDays = effectiveMaxDays(field);
3958
+ const [daysTextValid, setDaysTextValid] = React24.useState(true);
3781
3959
  const [kind, setKind] = React24.useState(value?.kind ?? "withinDays");
3782
3960
  const [daysDraft, setDaysDraft] = React24.useState(
3783
3961
  value?.kind === "withinDays" ? value.days : defaultDays
@@ -3785,42 +3963,26 @@ function DatePanel({
3785
3963
  const [between, setBetween] = React24.useState(
3786
3964
  value?.kind === "between" ? { from: value.from, to: value.to } : { from: "", to: "" }
3787
3965
  );
3788
- const committedRef = React24.useRef(!!value);
3789
3966
  const applyWithinDays = (days) => {
3790
3967
  setDaysDraft(days);
3791
- if (days != null) {
3792
- committedRef.current = true;
3793
- onChange({ kind: "withinDays", days });
3794
- } else if (committedRef.current) {
3795
- committedRef.current = false;
3796
- onRemove();
3797
- }
3968
+ onCommit(days == null ? void 0 : { kind: "withinDays", days });
3798
3969
  };
3799
3970
  const applyBetween = (from, to) => {
3800
3971
  setBetween({ from, to });
3801
- if (from && to) {
3802
- committedRef.current = true;
3803
- onChange({ kind: "between", from, to });
3804
- } else if (committedRef.current) {
3805
- committedRef.current = false;
3806
- onRemove();
3807
- }
3972
+ onCommit(from && to ? { kind: "between", from, to } : void 0);
3808
3973
  };
3809
3974
  const switchKind = (next) => {
3975
+ if (next === kind) return;
3810
3976
  setKind(next);
3811
- if (next === "between") {
3812
- setBetween({ from: "", to: "" });
3813
- if (committedRef.current) {
3814
- committedRef.current = false;
3815
- onRemove();
3816
- }
3817
- }
3977
+ if (next === "between") setBetween({ from: "", to: "" });
3978
+ else setDaysDraft(defaultDays);
3979
+ setDaysTextValid(true);
3980
+ onCommit(void 0);
3818
3981
  };
3982
+ const doneDisabled = kind === "withinDays" && !daysTextValid;
3819
3983
  const confirm = () => {
3820
- if (kind === "withinDays" && daysDraft != null) {
3821
- committedRef.current = true;
3822
- onChange({ kind: "withinDays", days: daysDraft });
3823
- }
3984
+ if (doneDisabled) return;
3985
+ if (kind === "withinDays" && daysDraft != null) onCommit({ kind: "withinDays", days: daysDraft });
3824
3986
  onClose();
3825
3987
  };
3826
3988
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
@@ -3833,7 +3995,7 @@ function DatePanel({
3833
3995
  offset: 6,
3834
3996
  width: PANEL_WIDTH.date,
3835
3997
  label: field.label,
3836
- footer: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: confirm, children: "Done" }),
3998
+ footer: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: confirm, disabled: doneDisabled, children: "Done" }),
3837
3999
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("span", { className: "fd-stack fd-gap-sm", children: [
3838
4000
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3839
4001
  SegmentedControl,
@@ -3847,7 +4009,19 @@ function DatePanel({
3847
4009
  onChange: (k) => switchKind(k)
3848
4010
  }
3849
4011
  ),
3850
- kind === "withinDays" ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(NumberInput, { label: "Days", min: 1, max: maxDays, step: 1, value: daysDraft ?? "", onChange: (e) => applyWithinDays(e.target.value) }) : /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
4012
+ kind === "withinDays" ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
4013
+ NumberInput,
4014
+ {
4015
+ label: "Days",
4016
+ min: 1,
4017
+ max: maxDays,
4018
+ step: 1,
4019
+ integer: true,
4020
+ value: daysDraft ?? "",
4021
+ onChange: (e) => applyWithinDays(e.target.value),
4022
+ onValidityChange: setDaysTextValid
4023
+ }
4024
+ ) : /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3851
4025
  DatePicker,
3852
4026
  {
3853
4027
  range: true,
@@ -3866,6 +4040,33 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3866
4040
  const addAnchorRef = React24.useRef(null);
3867
4041
  const textInputRef = React24.useRef(null);
3868
4042
  const optionsRef = React24.useRef(null);
4043
+ if (process.env.NODE_ENV !== "production") {
4044
+ for (const f of fields) assertValidFieldDefinition(f);
4045
+ }
4046
+ const latest = React24.useRef({ fields, filters, onFilterChange, onFilterRemove });
4047
+ latest.current = { fields, filters, onFilterChange, onFilterRemove };
4048
+ const commit = (field, candidate) => {
4049
+ const current2 = latest.current.fields.find((f) => f.key === field.key);
4050
+ if (!current2) return { ok: false, reason: "This filter is no longer available" };
4051
+ if (isEmptyCandidate(current2, candidate)) {
4052
+ removeField(current2);
4053
+ return { ok: true };
4054
+ }
4055
+ const result = validateFilterValue(current2, candidate);
4056
+ if (result.ok) {
4057
+ latest.current.filters = { ...latest.current.filters, [current2.key]: candidate };
4058
+ latest.current.onFilterChange(current2.key, candidate);
4059
+ }
4060
+ return result;
4061
+ };
4062
+ const removeField = (field) => {
4063
+ const { filters: now, onFilterRemove: remove } = latest.current;
4064
+ if (!isFieldSet(field, now[field.key])) return;
4065
+ const next = { ...now };
4066
+ delete next[field.key];
4067
+ latest.current.filters = next;
4068
+ remove(field.key);
4069
+ };
3869
4070
  const editingField = panel && panel.kind === "edit" ? fields.find((f) => f.key === panel.key) : void 0;
3870
4071
  const startEditing = (key) => setPanel({ kind: "edit", key });
3871
4072
  const closeEditing = () => setPanel(null);
@@ -3897,7 +4098,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3897
4098
  value: value ?? "",
3898
4099
  placeholder: field.placeholder || field.label,
3899
4100
  "aria-label": field.label,
3900
- onChange: (e) => onFilterChange(field.key, e.target.value),
4101
+ onChange: (e) => commit(field, e.target.value),
3901
4102
  onKeyDown: (e) => {
3902
4103
  if (e.key === "Enter") setPanel(null);
3903
4104
  else if (e.key === "Escape") {
@@ -3916,7 +4117,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3916
4117
  "aria-label": "Remove " + field.label,
3917
4118
  onClick: (e) => {
3918
4119
  e.stopPropagation();
3919
- onFilterRemove(field.key);
4120
+ removeField(field);
3920
4121
  setPanel(null);
3921
4122
  },
3922
4123
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("i", { className: "ph ph-x", "aria-hidden": "true" })
@@ -3929,7 +4130,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3929
4130
  const chipRef = editing ? editAnchorRef : void 0;
3930
4131
  const toggleOrClose = () => editing ? closeEditing() : startEditing(field.key);
3931
4132
  const remove = () => {
3932
- if (set) onFilterRemove(field.key);
4133
+ removeField(field);
3933
4134
  closeEditing();
3934
4135
  };
3935
4136
  const chipText = field.label + (set ? ": " + chipLabel(field, value) : "");
@@ -3959,7 +4160,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3959
4160
  "aria-selected": on,
3960
4161
  className: ["fd-opt", on ? "is-selected" : ""].filter(Boolean).join(" "),
3961
4162
  onClick: () => {
3962
- onFilterChange(field.key, o.value);
4163
+ commit(field, o.value);
3963
4164
  setPanel(null);
3964
4165
  },
3965
4166
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "fd-opt-label", children: o.label })
@@ -3980,8 +4181,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3980
4181
  field,
3981
4182
  value,
3982
4183
  anchorRef: editAnchorRef,
3983
- onChange: (v) => onFilterChange(field.key, v),
3984
- onRemove: () => onFilterRemove(field.key),
4184
+ onCommit: (v) => commit(field, v),
3985
4185
  onClose: closeEditing
3986
4186
  }
3987
4187
  ) : null
@@ -3996,8 +4196,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3996
4196
  field,
3997
4197
  value,
3998
4198
  anchorRef: editAnchorRef,
3999
- onChange: (v) => onFilterChange(field.key, v),
4000
- onRemove: () => onFilterRemove(field.key),
4199
+ onCommit: (v) => commit(field, v),
4001
4200
  onClose: closeEditing
4002
4201
  }
4003
4202
  ) : null
@@ -4012,8 +4211,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
4012
4211
  field,
4013
4212
  value,
4014
4213
  anchorRef: editAnchorRef,
4015
- onChange: (v) => onFilterChange(field.key, v),
4016
- onRemove: () => onFilterRemove(field.key),
4214
+ onCommit: (v) => commit(field, v),
4017
4215
  onClose: closeEditing
4018
4216
  }
4019
4217
  ) : null
@@ -13862,6 +14060,7 @@ function useDocumentTitle(routeTitle, appName) {
13862
14060
  useServerTable,
13863
14061
  useSessionContext,
13864
14062
  useStagedFiles,
14063
+ validateFilterValue,
13865
14064
  visibleMessages
13866
14065
  });
13867
14066
  //# sourceMappingURL=index.cjs.map