@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.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,
@@ -3021,70 +3064,78 @@ function NumberInput({
3021
3064
  }) {
3022
3065
  const fieldId = useFieldId(id);
3023
3066
  const errorId = fieldId + "-error";
3024
- const num = (v) => {
3025
- const s = String(v == null ? "" : v).replace(/[^0-9.-]/g, "");
3026
- if (s === "" || s === "-") return null;
3027
- const n = Number(s);
3028
- return isNaN(n) ? null : n;
3029
- };
3030
- const validationMessage = (n) => {
3031
- if (integer && !Number.isInteger(n)) return "Enter a whole number";
3032
- if (n < min) return "Must be \u2265 " + min;
3033
- if (n > max) return "Must be \u2264 " + max;
3034
- 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;
3035
3074
  };
3036
- const [text, setText] = React21.useState(value == null || value === "" ? "" : String(value));
3075
+ const [text, setText] = React21.useState(() => toText(value));
3037
3076
  const [editing, setEditing] = React21.useState(false);
3038
- const [invalidMessage, setInvalidMessage] = React21.useState(null);
3039
- const timer = React21.useRef(null);
3040
- React21.useEffect(() => {
3041
- if (!editing) {
3042
- setText(value == null || value === "" ? "" : String(value));
3043
- setInvalidMessage(null);
3044
- }
3045
- }, [value, editing]);
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);
3046
3086
  React21.useEffect(() => {
3047
- onValidityChange && onValidityChange(invalidMessage == null);
3048
- }, [invalidMessage]);
3049
- const clamp = (n) => Math.min(max, Math.max(min, n));
3050
- const fire = (n) => onChange && onChange({ target: { value: n } });
3051
- const bump = (dir, mult = 1) => {
3052
- const cur = (invalidMessage ? null : num(text)) ?? num(value) ?? (min === -Infinity ? 0 : min);
3053
- const amount = (bigStep && mult > 1 ? bigStep : step) * (bigStep ? 1 : mult);
3054
- let next = cur + dir * amount;
3055
- if (integer) next = Math.round(next);
3056
- next = clamp(next);
3057
- 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;
3058
3100
  setText(String(next));
3101
+ if (next === committedRef.current) return false;
3059
3102
  fire(next);
3103
+ return true;
3060
3104
  };
3061
- const hold = (dir) => {
3062
- bump(dir);
3063
- 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;
3064
3116
  const tick = () => {
3065
- n++;
3066
- bump(dir, n > 6 ? 4 : 1);
3067
- 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));
3068
3123
  };
3069
- timer.current = setTimeout(tick, 420);
3070
- };
3071
- const release = () => {
3072
- if (timer.current) clearTimeout(timer.current);
3124
+ holdTimer.current = setTimeout(tick, HOLD_DELAY_MS);
3073
3125
  };
3074
3126
  React21.useEffect(() => () => {
3075
- if (timer.current) clearTimeout(timer.current);
3127
+ if (holdTimer.current) clearTimeout(holdTimer.current);
3076
3128
  }, []);
3077
- const shown = editing ? text : (() => {
3078
- const n = num(text);
3079
- return n == null ? "" : format ? n.toLocaleString() : String(n);
3080
- })();
3129
+ const current2 = committedRef.current;
3130
+ const shown = editing || !valid ? text : parsed == null ? "" : format ? parsed.toLocaleString() : String(parsed);
3081
3131
  const shownError = error || invalidMessage || void 0;
3082
3132
  const box = ["fd-input", "fd-input-num", shownError ? "fd-input-error" : "", disabled ? "fd-input-disabled" : ""].filter(Boolean).join(" ");
3083
3133
  const fieldName = label || ariaLabel;
3084
3134
  const stepperName = (verb) => fieldName ? verb + " " + fieldName : verb;
3135
+ const noRange = lo > hi;
3085
3136
  return /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("div", { className: ["fd-field", className].filter(Boolean).join(" "), style, children: [
3086
3137
  label ? /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(FieldLabel, { htmlFor: fieldId, required, children: label }) : null,
3087
- /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("div", { className: box, style: { gap: 8 }, children: [
3138
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("div", { className: box, children: [
3088
3139
  prefix ? /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("span", { className: "fd-input-affix", children: prefix }) : null,
3089
3140
  /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(
3090
3141
  "input",
@@ -3097,52 +3148,54 @@ function NumberInput({
3097
3148
  value: shown,
3098
3149
  "aria-invalid": shownError ? "true" : void 0,
3099
3150
  "aria-describedby": shownError ? errorId : void 0,
3100
- onFocus: () => setEditing(true),
3151
+ onFocus: () => {
3152
+ focusedRef.current = true;
3153
+ setEditing(true);
3154
+ },
3101
3155
  onBlur: () => {
3156
+ focusedRef.current = false;
3102
3157
  setEditing(false);
3103
- if (text.trim() === "") fire(void 0);
3158
+ if (text.trim() === "" && committedRef.current != null) fire(void 0);
3104
3159
  },
3105
3160
  onChange: (e) => {
3106
3161
  const raw = e.target.value;
3107
3162
  setText(raw);
3108
- if (raw.trim() === "") {
3109
- setInvalidMessage(null);
3110
- fire(void 0);
3111
- return;
3112
- }
3113
- const n = num(raw);
3163
+ const n = parseNumberText(raw);
3114
3164
  if (n == null) {
3115
- setInvalidMessage("Enter a number");
3165
+ if (committedRef.current != null) fire(void 0);
3116
3166
  return;
3117
3167
  }
3118
- const message = validationMessage(n);
3119
- setInvalidMessage(message);
3120
- if (!message) fire(n);
3168
+ if (Number.isNaN(n) || integer && !Number.isInteger(n) || n < lo || n > hi) return;
3169
+ fire(n);
3121
3170
  },
3122
3171
  onKeyDown: (e) => {
3123
3172
  if (e.key === "ArrowUp") {
3124
3173
  e.preventDefault();
3125
- bump(1, e.shiftKey ? 2 : 1);
3174
+ bump(1, e.shiftKey ? big : step);
3126
3175
  } else if (e.key === "ArrowDown") {
3127
3176
  e.preventDefault();
3128
- bump(-1, e.shiftKey ? 2 : 1);
3177
+ bump(-1, e.shiftKey ? big : step);
3129
3178
  }
3130
3179
  }
3131
3180
  }
3132
3181
  ),
3133
3182
  suffix ? /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("span", { className: "fd-input-affix", children: suffix }) : null,
3134
- /* @__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: [
3135
3184
  /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(
3136
3185
  "button",
3137
3186
  {
3138
3187
  type: "button",
3139
3188
  className: "fd-stepbtn",
3140
3189
  "aria-label": stepperName("Decrease"),
3141
- disabled: disabled || (num(text) ?? 0) <= min,
3190
+ disabled: disabled || noRange || current2 != null && current2 <= lo,
3142
3191
  onPointerDown: () => hold(-1),
3143
3192
  onPointerUp: release,
3144
3193
  onPointerLeave: release,
3145
- 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" })
3146
3199
  }
3147
3200
  ),
3148
3201
  /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(
@@ -3151,16 +3204,21 @@ function NumberInput({
3151
3204
  type: "button",
3152
3205
  className: "fd-stepbtn",
3153
3206
  "aria-label": stepperName("Increase"),
3154
- disabled: disabled || (num(text) ?? 0) >= max,
3207
+ disabled: disabled || noRange || current2 != null && current2 >= hi,
3155
3208
  onPointerDown: () => hold(1),
3156
3209
  onPointerUp: release,
3157
3210
  onPointerLeave: release,
3158
- 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" })
3159
3216
  }
3160
3217
  )
3161
3218
  ] })
3162
3219
  ] }),
3163
- shownError ? /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("span", { id: errorId, className: "fd-field-error", children: [
3220
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("span", { id: errorId, className: "fd-sr", role: "status", children: shownError ?? "" }),
3221
+ shownError ? /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("span", { className: "fd-field-error", "aria-hidden": "true", children: [
3164
3222
  /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("i", { className: "ph ph-warning-circle", "aria-hidden": "true" }),
3165
3223
  shownError
3166
3224
  ] }) : help ? /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("span", { className: "fd-field-help", children: help }) : null
@@ -3440,16 +3498,31 @@ function isFieldSet(field, value) {
3440
3498
  }
3441
3499
  }
3442
3500
  }
3501
+ var MAX_WITHIN_DAYS = 3650;
3502
+ function effectiveMaxDays(field) {
3503
+ return Math.min(field.maxDays ?? MAX_WITHIN_DAYS, MAX_WITHIN_DAYS);
3504
+ }
3505
+ function isIsoDate(v) {
3506
+ if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
3507
+ const d = /* @__PURE__ */ new Date(v + "T00:00:00Z");
3508
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
3509
+ }
3510
+ var isOption = (o) => !!o && typeof o.value === "string" && typeof o.label === "string";
3443
3511
  function validateFilterValue(field, value) {
3444
3512
  switch (field.type) {
3445
3513
  case "text":
3514
+ return typeof value === "string" ? { ok: true } : { ok: false, reason: "Enter text" };
3446
3515
  case "select":
3447
- return { ok: true };
3516
+ return field.options.some((o) => o.value === value) ? { ok: true } : { ok: false, reason: "Pick one of the options" };
3448
3517
  case "multiSelect": {
3449
3518
  const v = value;
3450
3519
  if (!v || !Array.isArray(v.values) || v.values.length === 0) {
3451
3520
  return { ok: false, reason: "Select at least one value" };
3452
3521
  }
3522
+ if (!v.values.every(isOption)) return { ok: false, reason: "Each value needs a value and a label" };
3523
+ if (field.maxValues != null && v.values.length > field.maxValues) {
3524
+ return { ok: false, reason: "Select at most " + field.maxValues + " values" };
3525
+ }
3453
3526
  if (!operatorsFor(field).includes(v.operator)) {
3454
3527
  return { ok: false, reason: "That operator isn't allowed for " + field.label };
3455
3528
  }
@@ -3464,6 +3537,7 @@ function validateFilterValue(field, value) {
3464
3537
  const hi = field.max ?? Infinity;
3465
3538
  for (const n of [v.min, v.max]) {
3466
3539
  if (n == null) continue;
3540
+ if (typeof n !== "number" || !Number.isFinite(n)) return { ok: false, reason: "Enter a number" };
3467
3541
  if (field.integer && !Number.isInteger(n)) return { ok: false, reason: "Enter a whole number" };
3468
3542
  if (n < lo) return { ok: false, reason: "Must be \u2265 " + lo };
3469
3543
  if (n > hi) return { ok: false, reason: "Must be \u2264 " + hi };
@@ -3477,46 +3551,36 @@ function validateFilterValue(field, value) {
3477
3551
  const v = value;
3478
3552
  if (!v) return { ok: false, reason: "Pick a date value" };
3479
3553
  if (v.kind === "withinDays") {
3480
- const maxDays = field.maxDays ?? 3650;
3554
+ const maxDays = effectiveMaxDays(field);
3481
3555
  if (v.days == null) return { ok: false, reason: "Enter a number of days" };
3482
3556
  if (!Number.isInteger(v.days)) return { ok: false, reason: "Enter a whole number" };
3483
3557
  if (v.days < 1) return { ok: false, reason: "Must be \u2265 1" };
3484
3558
  if (v.days > maxDays) return { ok: false, reason: "Must be \u2264 " + maxDays };
3485
3559
  return { ok: true };
3486
3560
  }
3561
+ if (v.kind !== "between") return { ok: false, reason: "Pick a date value" };
3487
3562
  if (!v.from || !v.to) return { ok: false, reason: "Pick both dates" };
3563
+ if (!isIsoDate(v.from) || !isIsoDate(v.to)) return { ok: false, reason: "Dates must be YYYY-MM-DD" };
3488
3564
  if (v.from > v.to) return { ok: false, reason: "The start date must be on or before the end date" };
3489
3565
  return { ok: true };
3490
3566
  }
3491
3567
  }
3492
3568
  }
3493
3569
  function isEmptyCandidate(field, candidate) {
3494
- switch (field.type) {
3495
- case "text":
3496
- case "select":
3497
- return false;
3498
- case "multiSelect": {
3499
- const v = candidate;
3500
- return !v || !Array.isArray(v.values) || v.values.length === 0;
3501
- }
3502
- case "range": {
3503
- const v = candidate;
3504
- return !v || v.min == null && v.max == null;
3505
- }
3506
- case "date": {
3507
- const v = candidate;
3508
- if (!v) return true;
3509
- return v.kind === "withinDays" ? v.days == null : !(v.from && v.to);
3510
- }
3511
- }
3570
+ return field.type !== "text" && field.type !== "select" && !isFieldSet(field, candidate);
3512
3571
  }
3513
- function assertValidDateField(field) {
3514
- if (process.env.NODE_ENV === "production") return;
3572
+ function assertValidFieldDefinition(field) {
3573
+ if (field.type === "multiSelect" && field.maxValues != null && (!Number.isInteger(field.maxValues) || field.maxValues < 1)) {
3574
+ throw new Error(
3575
+ 'FilterBar: multiSelect field "' + field.key + '" has an invalid maxValues (' + field.maxValues + ") \u2014 must be a whole number of at least 1."
3576
+ );
3577
+ }
3578
+ if (field.type !== "date") return;
3515
3579
  const defaultDays = field.defaultDays ?? 30;
3516
- const maxDays = field.maxDays ?? 3650;
3517
- if (!Number.isInteger(maxDays) || maxDays < 1 || maxDays > 3650) {
3580
+ const maxDays = field.maxDays ?? MAX_WITHIN_DAYS;
3581
+ if (!Number.isInteger(maxDays) || maxDays < 1 || maxDays > MAX_WITHIN_DAYS) {
3518
3582
  throw new Error(
3519
- 'FilterBar: date field "' + field.key + '" has an invalid maxDays (' + field.maxDays + ") \u2014 must be a whole number from 1 to 3650."
3583
+ 'FilterBar: date field "' + field.key + '" has an invalid maxDays (' + field.maxDays + ") \u2014 must be a whole number from 1 to " + MAX_WITHIN_DAYS + "."
3520
3584
  );
3521
3585
  }
3522
3586
  if (!Number.isInteger(defaultDays) || defaultDays < 1 || defaultDays > maxDays) {
@@ -3595,12 +3659,10 @@ function MultiSelectPanel({
3595
3659
  mountedRef.current = false;
3596
3660
  };
3597
3661
  }, []);
3598
- const commit = (nextValues, nextOperator = operator) => {
3599
- onCommit({ operator: nextOperator, values: nextValues });
3600
- };
3662
+ const commit = (nextValues, nextOperator = operator) => onCommit({ operator: nextOperator, values: nextValues });
3601
3663
  const setOperator = (op) => {
3602
3664
  setDraftOperator(op);
3603
- if (selected.length > 0) commit(selected, op);
3665
+ if (selectedRef.current.length > 0) commit(selectedRef.current, op);
3604
3666
  };
3605
3667
  const [mode2, setMode] = React24.useState("search");
3606
3668
  const [query, setQuery] = React24.useState("");
@@ -3640,9 +3702,12 @@ function MultiSelectPanel({
3640
3702
  const showError = !!field.loadOptions && asyncState.error;
3641
3703
  const hasListbox = !showLoading && !showError && options.length > 0;
3642
3704
  const isSelected = (o) => selected.some((s) => s.value === o.value);
3705
+ const atLimit = field.maxValues != null && selected.length >= field.maxValues;
3643
3706
  const toggle = (o) => {
3644
- const next = isSelected(o) ? selected.filter((s) => s.value !== o.value) : [...selected, o];
3645
- commit(next);
3707
+ const current2 = selectedRef.current;
3708
+ const on = current2.some((s) => s.value === o.value);
3709
+ if (!on && field.maxValues != null && current2.length >= field.maxValues) return;
3710
+ commit(on ? current2.filter((s) => s.value !== o.value) : [...current2, o]);
3646
3711
  };
3647
3712
  const nav = useListboxNav({ count: options.length, onSelect: (i) => toggle(options[i]) });
3648
3713
  useListboxScrollIntoView(nav.active, listRef, hasListbox);
@@ -3650,8 +3715,7 @@ function MultiSelectPanel({
3650
3715
  nav.setActive(0);
3651
3716
  }, [query, options.length]);
3652
3717
  const activeId = hasListbox && options[nav.active] ? listId + "-opt-" + nav.active : void 0;
3653
- const liveRole = showError ? "alert" : "status";
3654
- const liveMessage = showLoading ? "Loading\u2026" : showError ? "Couldn't load options." : options.length === 0 ? "No matches" : "";
3718
+ const liveMessage = showLoading ? "Loading\u2026" : showError ? "Couldn't load options." : options.length === 0 ? "No matches" : atLimit ? "Limit reached: at most " + field.maxValues + " values" : "";
3655
3719
  const [pasteText, setPasteText] = React24.useState("");
3656
3720
  const [pasteLoading, setPasteLoading] = React24.useState(false);
3657
3721
  const [unmatchedTokens, setUnmatchedTokens] = React24.useState([]);
@@ -3672,9 +3736,17 @@ function MultiSelectPanel({
3672
3736
  setUnmatchedTokens(result.unmatched);
3673
3737
  const merged = new Map(selectedRef.current.map((s) => [s.value, s]));
3674
3738
  for (const m of result.matched) merged.set(m.value, { value: m.value, label: m.label });
3675
- commit(Array.from(merged.values()), operatorRef.current);
3739
+ if (field.maxValues != null && merged.size > field.maxValues) {
3740
+ setPasteError("This filter allows at most " + field.maxValues + " values; adding this list would make " + merged.size + ".");
3741
+ return;
3742
+ }
3743
+ const applied = commit(Array.from(merged.values()), operatorRef.current);
3744
+ if (!applied.ok) {
3745
+ setPasteError(applied.reason);
3746
+ return;
3747
+ }
3676
3748
  setPasteText("");
3677
- }).catch((err) => {
3749
+ }, (err) => {
3678
3750
  if (!mountedRef.current) return;
3679
3751
  setPasteLoading(false);
3680
3752
  setPasteError(err instanceof Error ? err.message : "Could not resolve the pasted list.");
@@ -3753,14 +3825,14 @@ function MultiSelectPanel({
3753
3825
  selected.length,
3754
3826
  " matched"
3755
3827
  ] }),
3756
- /* @__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)) })
3828
+ /* @__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)) })
3757
3829
  ] }) : null
3758
3830
  ] }) : /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("span", { className: "fd-stack fd-gap-sm", children: [
3759
3831
  /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)(
3760
3832
  "span",
3761
3833
  {
3762
3834
  className: "fd-row fd-gap-xs" + (liveMessage && !showError ? " fd-muted" : ""),
3763
- role: liveRole,
3835
+ role: "status",
3764
3836
  children: [
3765
3837
  showLoading ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "fd-spinner", "aria-hidden": "true" }) : null,
3766
3838
  liveMessage ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "fd-body-sm", children: liveMessage }) : null
@@ -3770,6 +3842,7 @@ function MultiSelectPanel({
3770
3842
  showError ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: () => setRetryTick((n) => n + 1), children: "Retry" }) : null,
3771
3843
  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) => {
3772
3844
  const on = isSelected(o);
3845
+ const blocked = !on && atLimit;
3773
3846
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)(
3774
3847
  "div",
3775
3848
  {
@@ -3778,6 +3851,7 @@ function MultiSelectPanel({
3778
3851
  role: "option",
3779
3852
  tabIndex: -1,
3780
3853
  "aria-selected": on,
3854
+ "aria-disabled": blocked ? "true" : void 0,
3781
3855
  className: ["fd-opt", i === nav.active ? "is-active" : "", on ? "is-selected" : ""].filter(Boolean).join(" "),
3782
3856
  onMouseEnter: () => nav.setActive(i),
3783
3857
  onClick: () => toggle(o),
@@ -3807,6 +3881,34 @@ function MultiSelectPanel({
3807
3881
  }
3808
3882
  );
3809
3883
  }
3884
+ function useTextValidity(onFlip) {
3885
+ const invalidRef = React24.useRef(/* @__PURE__ */ new Set());
3886
+ const [, rerender] = React24.useReducer((n) => n + 1, 0);
3887
+ const onFlipRef = React24.useRef(onFlip);
3888
+ onFlipRef.current = onFlip;
3889
+ const record = (name, valid) => {
3890
+ const invalid = invalidRef.current;
3891
+ if (valid !== invalid.has(name)) return false;
3892
+ if (valid) invalid.delete(name);
3893
+ else invalid.add(name);
3894
+ rerender();
3895
+ return true;
3896
+ };
3897
+ return {
3898
+ allValid: () => invalidRef.current.size === 0,
3899
+ report: (name) => (valid) => {
3900
+ if (record(name, valid)) onFlipRef.current();
3901
+ },
3902
+ /** For an input that is about to unmount: it has nothing left to show. */
3903
+ forget: (name) => {
3904
+ record(name, true);
3905
+ }
3906
+ };
3907
+ }
3908
+ function applicable(field, candidate, textValid) {
3909
+ if (!textValid || candidate === void 0) return void 0;
3910
+ return validateFilterValue(field, candidate).ok ? candidate : void 0;
3911
+ }
3810
3912
  function RangePanel({
3811
3913
  field,
3812
3914
  value,
@@ -3814,18 +3916,24 @@ function RangePanel({
3814
3916
  onCommit,
3815
3917
  onClose
3816
3918
  }) {
3817
- const [draftMin, setDraftMin] = React24.useState(value?.min);
3818
- const [draftMax, setDraftMax] = React24.useState(value?.max);
3819
- const [minTextValid, setMinTextValid] = React24.useState(true);
3820
- const [maxTextValid, setMaxTextValid] = React24.useState(true);
3919
+ const [draft, setDraft] = React24.useState({ min: value?.min, max: value?.max });
3920
+ const draftRef = React24.useRef(draft);
3921
+ const publish = () => {
3922
+ const { min, max } = draftRef.current;
3923
+ onCommit(applicable(field, min == null && max == null ? void 0 : { min, max }, validity.allValid()));
3924
+ };
3925
+ const validity = useTextValidity(publish);
3926
+ const draftMin = draft.min;
3927
+ const draftMax = draft.max;
3821
3928
  const isEmpty = draftMin == null && draftMax == null;
3822
3929
  const validation = isEmpty ? { ok: true } : validateFilterValue(field, { min: draftMin, max: draftMax });
3823
3930
  const comboError = !isEmpty && !validation.ok ? validation.reason : void 0;
3824
- const doneDisabled = !minTextValid || !maxTextValid || !!comboError;
3825
- const apply = (nextMin, nextMax) => {
3826
- setDraftMin(nextMin);
3827
- setDraftMax(nextMax);
3828
- onCommit(nextMin == null && nextMax == null ? void 0 : { min: nextMin, max: nextMax });
3931
+ const doneDisabled = !validity.allValid() || !!comboError;
3932
+ const apply = (patch) => {
3933
+ const next = { ...draftRef.current, ...patch };
3934
+ draftRef.current = next;
3935
+ setDraft(next);
3936
+ publish();
3829
3937
  };
3830
3938
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3831
3939
  Popover,
@@ -3849,8 +3957,8 @@ function RangePanel({
3849
3957
  step: field.step,
3850
3958
  integer: field.integer,
3851
3959
  placeholder: field.minPlaceholder,
3852
- onChange: (e) => apply(e.target.value, draftMax),
3853
- onValidityChange: setMinTextValid
3960
+ onChange: (e) => apply({ min: e.target.value }),
3961
+ onValidityChange: validity.report("min")
3854
3962
  }
3855
3963
  ),
3856
3964
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
@@ -3864,8 +3972,8 @@ function RangePanel({
3864
3972
  integer: field.integer,
3865
3973
  error: comboError,
3866
3974
  placeholder: field.maxPlaceholder,
3867
- onChange: (e) => apply(draftMin, e.target.value),
3868
- onValidityChange: setMaxTextValid
3975
+ onChange: (e) => apply({ max: e.target.value }),
3976
+ onValidityChange: validity.report("max")
3869
3977
  }
3870
3978
  )
3871
3979
  ] })
@@ -3880,31 +3988,48 @@ function DatePanel({
3880
3988
  onClose
3881
3989
  }) {
3882
3990
  const defaultDays = field.defaultDays ?? 30;
3883
- const maxDays = field.maxDays ?? 3650;
3991
+ const maxDays = effectiveMaxDays(field);
3884
3992
  const [kind, setKind] = React24.useState(value?.kind ?? "withinDays");
3885
3993
  const [daysDraft, setDaysDraft] = React24.useState(
3886
3994
  value?.kind === "withinDays" ? value.days : defaultDays
3887
3995
  );
3996
+ const daysRef = React24.useRef(daysDraft);
3888
3997
  const [between, setBetween] = React24.useState(
3889
3998
  value?.kind === "between" ? { from: value.from, to: value.to } : { from: "", to: "" }
3890
3999
  );
4000
+ const shownWithinDays = () => {
4001
+ const days = daysRef.current;
4002
+ return applicable(field, days == null ? void 0 : { kind: "withinDays", days }, validity.allValid());
4003
+ };
4004
+ const validity = useTextValidity(() => onCommit(shownWithinDays()));
3891
4005
  const applyWithinDays = (days) => {
4006
+ daysRef.current = days;
3892
4007
  setDaysDraft(days);
3893
- onCommit(days == null ? void 0 : { kind: "withinDays", days });
4008
+ onCommit(shownWithinDays());
3894
4009
  };
3895
4010
  const applyBetween = (from, to) => {
3896
4011
  setBetween({ from, to });
3897
- onCommit(from && to ? { kind: "between", from, to } : void 0);
4012
+ onCommit(applicable(field, from && to ? { kind: "between", from, to } : void 0, true));
3898
4013
  };
3899
4014
  const switchKind = (next) => {
3900
4015
  if (next === kind) return;
3901
4016
  setKind(next);
3902
- if (next === "between") setBetween({ from: "", to: "" });
3903
- else setDaysDraft(defaultDays);
4017
+ if (next === "between") {
4018
+ setBetween({ from: "", to: "" });
4019
+ validity.forget("days");
4020
+ } else {
4021
+ daysRef.current = defaultDays;
4022
+ setDaysDraft(defaultDays);
4023
+ }
3904
4024
  onCommit(void 0);
3905
4025
  };
4026
+ const doneDisabled = kind === "withinDays" && !validity.allValid();
3906
4027
  const confirm = () => {
3907
- if (kind === "withinDays" && daysDraft != null) onCommit({ kind: "withinDays", days: daysDraft });
4028
+ if (doneDisabled) return;
4029
+ if (kind === "withinDays") {
4030
+ const shown = shownWithinDays();
4031
+ if (shown) onCommit(shown);
4032
+ }
3908
4033
  onClose();
3909
4034
  };
3910
4035
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
@@ -3917,7 +4042,7 @@ function DatePanel({
3917
4042
  offset: 6,
3918
4043
  width: PANEL_WIDTH.date,
3919
4044
  label: field.label,
3920
- footer: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: confirm, children: "Done" }),
4045
+ footer: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: confirm, disabled: doneDisabled, children: "Done" }),
3921
4046
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("span", { className: "fd-stack fd-gap-sm", children: [
3922
4047
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3923
4048
  SegmentedControl,
@@ -3931,7 +4056,19 @@ function DatePanel({
3931
4056
  onChange: (k) => switchKind(k)
3932
4057
  }
3933
4058
  ),
3934
- kind === "withinDays" ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(NumberInput, { label: "Days", min: 1, max: maxDays, step: 1, integer: true, value: daysDraft ?? "", onChange: (e) => applyWithinDays(e.target.value) }) : /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
4059
+ kind === "withinDays" ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
4060
+ NumberInput,
4061
+ {
4062
+ label: "Days",
4063
+ min: 1,
4064
+ max: maxDays,
4065
+ step: 1,
4066
+ integer: true,
4067
+ value: daysDraft ?? "",
4068
+ onChange: (e) => applyWithinDays(e.target.value),
4069
+ onValidityChange: validity.report("days")
4070
+ }
4071
+ ) : /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3935
4072
  DatePicker,
3936
4073
  {
3937
4074
  range: true,
@@ -3951,19 +4088,38 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3951
4088
  const textInputRef = React24.useRef(null);
3952
4089
  const optionsRef = React24.useRef(null);
3953
4090
  if (process.env.NODE_ENV !== "production") {
3954
- for (const f of fields) if (f.type === "date") assertValidDateField(f);
4091
+ for (const f of fields) assertValidFieldDefinition(f);
3955
4092
  }
4093
+ const latest = React24.useRef({ fields, filters, onFilterChange, onFilterRemove });
4094
+ const filtersProp = React24.useRef(filters);
4095
+ if (filters !== filtersProp.current) {
4096
+ filtersProp.current = filters;
4097
+ latest.current.filters = filters;
4098
+ }
4099
+ latest.current.fields = fields;
4100
+ latest.current.onFilterChange = onFilterChange;
4101
+ latest.current.onFilterRemove = onFilterRemove;
3956
4102
  const commit = (field, candidate) => {
3957
- if (isEmptyCandidate(field, candidate)) {
3958
- removeField(field);
4103
+ const current2 = latest.current.fields.find((f) => f.key === field.key);
4104
+ if (!current2) return { ok: false, reason: "This filter is no longer available" };
4105
+ if (isEmptyCandidate(current2, candidate)) {
4106
+ removeField(current2);
3959
4107
  return { ok: true };
3960
4108
  }
3961
- const result = validateFilterValue(field, candidate);
3962
- if (result.ok) onFilterChange(field.key, candidate);
4109
+ const result = validateFilterValue(current2, candidate);
4110
+ if (result.ok) {
4111
+ latest.current.filters = { ...latest.current.filters, [current2.key]: candidate };
4112
+ latest.current.onFilterChange(current2.key, candidate);
4113
+ }
3963
4114
  return result;
3964
4115
  };
3965
4116
  const removeField = (field) => {
3966
- if (isFieldSet(field, filters[field.key])) onFilterRemove(field.key);
4117
+ const { filters: now, onFilterRemove: remove } = latest.current;
4118
+ if (!isFieldSet(field, now[field.key])) return;
4119
+ const next = { ...now };
4120
+ delete next[field.key];
4121
+ latest.current.filters = next;
4122
+ remove(field.key);
3967
4123
  };
3968
4124
  const editingField = panel && panel.kind === "edit" ? fields.find((f) => f.key === panel.key) : void 0;
3969
4125
  const startEditing = (key) => setPanel({ kind: "edit", key });
@@ -13958,6 +14114,7 @@ function useDocumentTitle(routeTitle, appName) {
13958
14114
  useServerTable,
13959
14115
  useSessionContext,
13960
14116
  useStagedFiles,
14117
+ validateFilterValue,
13961
14118
  visibleMessages
13962
14119
  });
13963
14120
  //# sourceMappingURL=index.cjs.map