@flytedan/flytebot-design-system 0.31.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/README.md CHANGED
@@ -81,7 +81,7 @@ pnpm run docs
81
81
  pnpm run build # tsup — emits dist/{index.js,index.cjs,index.d.ts}
82
82
  pnpm run typecheck # tsc --noEmit
83
83
  pnpm run lint # eslint .
84
- pnpm run test # vitest run
84
+ pnpm run test # tsup, then vitest run (some tests check the built dist/)
85
85
  ```
86
86
 
87
87
  ## Releasing
@@ -112,9 +112,9 @@ bash scripts/release.sh <new-version> "<summary>"
112
112
  - fetches `origin` and checks out `origin/main`'s exact tip — never unmerged
113
113
  work;
114
114
  - installs with `pnpm install --frozen-lockfile`;
115
- - runs the gates: `vitest run`, `tsc --noEmit`, `tsc --noEmit -p
116
- tsconfig.docs.json`, the docs `vite build`, `tsup`, and `lint` if the repo
117
- has that script;
115
+ - runs the gates: `tsup` (first, since some tests check the built `dist/`),
116
+ `vitest run`, `tsc --noEmit`, `tsc --noEmit -p tsconfig.docs.json`, the docs
117
+ `vite build`, and `lint` if the repo has that script;
118
118
  - refuses `<new-version>` unless it is greater than both the working
119
119
  checkout's `package.json` version and npm's currently published `latest`;
120
120
  - bumps `package.json`, commits `chore(release): <new-version> — <summary>`,
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);
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);
3040
3086
  React21.useEffect(() => {
3041
- if (!editing) {
3042
- setText(value == null || value === "" ? "" : String(value));
3043
- setInvalidMessage(null);
3044
- }
3045
- }, [value, editing]);
3046
- 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,11 +3204,15 @@ 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
  ] })
@@ -3440,16 +3497,31 @@ function isFieldSet(field, value) {
3440
3497
  }
3441
3498
  }
3442
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";
3443
3510
  function validateFilterValue(field, value) {
3444
3511
  switch (field.type) {
3445
3512
  case "text":
3513
+ return typeof value === "string" ? { ok: true } : { ok: false, reason: "Enter text" };
3446
3514
  case "select":
3447
- return { ok: true };
3515
+ return field.options.some((o) => o.value === value) ? { ok: true } : { ok: false, reason: "Pick one of the options" };
3448
3516
  case "multiSelect": {
3449
3517
  const v = value;
3450
3518
  if (!v || !Array.isArray(v.values) || v.values.length === 0) {
3451
3519
  return { ok: false, reason: "Select at least one value" };
3452
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
+ }
3453
3525
  if (!operatorsFor(field).includes(v.operator)) {
3454
3526
  return { ok: false, reason: "That operator isn't allowed for " + field.label };
3455
3527
  }
@@ -3464,6 +3536,7 @@ function validateFilterValue(field, value) {
3464
3536
  const hi = field.max ?? Infinity;
3465
3537
  for (const n of [v.min, v.max]) {
3466
3538
  if (n == null) continue;
3539
+ if (typeof n !== "number" || !Number.isFinite(n)) return { ok: false, reason: "Enter a number" };
3467
3540
  if (field.integer && !Number.isInteger(n)) return { ok: false, reason: "Enter a whole number" };
3468
3541
  if (n < lo) return { ok: false, reason: "Must be \u2265 " + lo };
3469
3542
  if (n > hi) return { ok: false, reason: "Must be \u2264 " + hi };
@@ -3477,46 +3550,36 @@ function validateFilterValue(field, value) {
3477
3550
  const v = value;
3478
3551
  if (!v) return { ok: false, reason: "Pick a date value" };
3479
3552
  if (v.kind === "withinDays") {
3480
- const maxDays = field.maxDays ?? 3650;
3553
+ const maxDays = effectiveMaxDays(field);
3481
3554
  if (v.days == null) return { ok: false, reason: "Enter a number of days" };
3482
3555
  if (!Number.isInteger(v.days)) return { ok: false, reason: "Enter a whole number" };
3483
3556
  if (v.days < 1) return { ok: false, reason: "Must be \u2265 1" };
3484
3557
  if (v.days > maxDays) return { ok: false, reason: "Must be \u2264 " + maxDays };
3485
3558
  return { ok: true };
3486
3559
  }
3560
+ if (v.kind !== "between") return { ok: false, reason: "Pick a date value" };
3487
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" };
3488
3563
  if (v.from > v.to) return { ok: false, reason: "The start date must be on or before the end date" };
3489
3564
  return { ok: true };
3490
3565
  }
3491
3566
  }
3492
3567
  }
3493
3568
  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
- }
3569
+ return field.type !== "text" && field.type !== "select" && !isFieldSet(field, candidate);
3512
3570
  }
3513
- function assertValidDateField(field) {
3514
- if (process.env.NODE_ENV === "production") return;
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;
3515
3578
  const defaultDays = field.defaultDays ?? 30;
3516
- const maxDays = field.maxDays ?? 3650;
3517
- if (!Number.isInteger(maxDays) || maxDays < 1 || maxDays > 3650) {
3579
+ const maxDays = field.maxDays ?? MAX_WITHIN_DAYS;
3580
+ if (!Number.isInteger(maxDays) || maxDays < 1 || maxDays > MAX_WITHIN_DAYS) {
3518
3581
  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."
3582
+ 'FilterBar: date field "' + field.key + '" has an invalid maxDays (' + field.maxDays + ") \u2014 must be a whole number from 1 to " + MAX_WITHIN_DAYS + "."
3520
3583
  );
3521
3584
  }
3522
3585
  if (!Number.isInteger(defaultDays) || defaultDays < 1 || defaultDays > maxDays) {
@@ -3600,7 +3663,7 @@ function MultiSelectPanel({
3600
3663
  };
3601
3664
  const setOperator = (op) => {
3602
3665
  setDraftOperator(op);
3603
- if (selected.length > 0) commit(selected, op);
3666
+ if (selectedRef.current.length > 0) commit(selectedRef.current, op);
3604
3667
  };
3605
3668
  const [mode2, setMode] = React24.useState("search");
3606
3669
  const [query, setQuery] = React24.useState("");
@@ -3640,9 +3703,12 @@ function MultiSelectPanel({
3640
3703
  const showError = !!field.loadOptions && asyncState.error;
3641
3704
  const hasListbox = !showLoading && !showError && options.length > 0;
3642
3705
  const isSelected = (o) => selected.some((s) => s.value === o.value);
3706
+ const atLimit = field.maxValues != null && selected.length >= field.maxValues;
3643
3707
  const toggle = (o) => {
3644
- const next = isSelected(o) ? selected.filter((s) => s.value !== o.value) : [...selected, o];
3645
- 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]);
3646
3712
  };
3647
3713
  const nav = useListboxNav({ count: options.length, onSelect: (i) => toggle(options[i]) });
3648
3714
  useListboxScrollIntoView(nav.active, listRef, hasListbox);
@@ -3650,8 +3716,7 @@ function MultiSelectPanel({
3650
3716
  nav.setActive(0);
3651
3717
  }, [query, options.length]);
3652
3718
  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" : "";
3719
+ const liveMessage = showLoading ? "Loading\u2026" : showError ? "Couldn't load options." : options.length === 0 ? "No matches" : atLimit ? "Limit reached: at most " + field.maxValues + " values" : "";
3655
3720
  const [pasteText, setPasteText] = React24.useState("");
3656
3721
  const [pasteLoading, setPasteLoading] = React24.useState(false);
3657
3722
  const [unmatchedTokens, setUnmatchedTokens] = React24.useState([]);
@@ -3672,9 +3737,13 @@ function MultiSelectPanel({
3672
3737
  setUnmatchedTokens(result.unmatched);
3673
3738
  const merged = new Map(selectedRef.current.map((s) => [s.value, s]));
3674
3739
  for (const m of result.matched) merged.set(m.value, { value: m.value, label: m.label });
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
+ }
3675
3744
  commit(Array.from(merged.values()), operatorRef.current);
3676
3745
  setPasteText("");
3677
- }).catch((err) => {
3746
+ }, (err) => {
3678
3747
  if (!mountedRef.current) return;
3679
3748
  setPasteLoading(false);
3680
3749
  setPasteError(err instanceof Error ? err.message : "Could not resolve the pasted list.");
@@ -3753,14 +3822,14 @@ function MultiSelectPanel({
3753
3822
  selected.length,
3754
3823
  " matched"
3755
3824
  ] }),
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)) })
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)) })
3757
3826
  ] }) : null
3758
3827
  ] }) : /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("span", { className: "fd-stack fd-gap-sm", children: [
3759
3828
  /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)(
3760
3829
  "span",
3761
3830
  {
3762
3831
  className: "fd-row fd-gap-xs" + (liveMessage && !showError ? " fd-muted" : ""),
3763
- role: liveRole,
3832
+ role: "status",
3764
3833
  children: [
3765
3834
  showLoading ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "fd-spinner", "aria-hidden": "true" }) : null,
3766
3835
  liveMessage ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "fd-body-sm", children: liveMessage }) : null
@@ -3770,6 +3839,7 @@ function MultiSelectPanel({
3770
3839
  showError ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: () => setRetryTick((n) => n + 1), children: "Retry" }) : null,
3771
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) => {
3772
3841
  const on = isSelected(o);
3842
+ const blocked = !on && atLimit;
3773
3843
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)(
3774
3844
  "div",
3775
3845
  {
@@ -3778,6 +3848,7 @@ function MultiSelectPanel({
3778
3848
  role: "option",
3779
3849
  tabIndex: -1,
3780
3850
  "aria-selected": on,
3851
+ "aria-disabled": blocked ? "true" : void 0,
3781
3852
  className: ["fd-opt", i === nav.active ? "is-active" : "", on ? "is-selected" : ""].filter(Boolean).join(" "),
3782
3853
  onMouseEnter: () => nav.setActive(i),
3783
3854
  onClick: () => toggle(o),
@@ -3814,18 +3885,21 @@ function RangePanel({
3814
3885
  onCommit,
3815
3886
  onClose
3816
3887
  }) {
3817
- const [draftMin, setDraftMin] = React24.useState(value?.min);
3818
- const [draftMax, setDraftMax] = React24.useState(value?.max);
3888
+ const [draft, setDraft] = React24.useState({ min: value?.min, max: value?.max });
3889
+ const draftRef = React24.useRef(draft);
3819
3890
  const [minTextValid, setMinTextValid] = React24.useState(true);
3820
3891
  const [maxTextValid, setMaxTextValid] = React24.useState(true);
3892
+ const draftMin = draft.min;
3893
+ const draftMax = draft.max;
3821
3894
  const isEmpty = draftMin == null && draftMax == null;
3822
3895
  const validation = isEmpty ? { ok: true } : validateFilterValue(field, { min: draftMin, max: draftMax });
3823
3896
  const comboError = !isEmpty && !validation.ok ? validation.reason : void 0;
3824
3897
  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 });
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);
3829
3903
  };
3830
3904
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3831
3905
  Popover,
@@ -3849,7 +3923,7 @@ function RangePanel({
3849
3923
  step: field.step,
3850
3924
  integer: field.integer,
3851
3925
  placeholder: field.minPlaceholder,
3852
- onChange: (e) => apply(e.target.value, draftMax),
3926
+ onChange: (e) => apply({ min: e.target.value }),
3853
3927
  onValidityChange: setMinTextValid
3854
3928
  }
3855
3929
  ),
@@ -3864,7 +3938,7 @@ function RangePanel({
3864
3938
  integer: field.integer,
3865
3939
  error: comboError,
3866
3940
  placeholder: field.maxPlaceholder,
3867
- onChange: (e) => apply(draftMin, e.target.value),
3941
+ onChange: (e) => apply({ max: e.target.value }),
3868
3942
  onValidityChange: setMaxTextValid
3869
3943
  }
3870
3944
  )
@@ -3880,7 +3954,8 @@ function DatePanel({
3880
3954
  onClose
3881
3955
  }) {
3882
3956
  const defaultDays = field.defaultDays ?? 30;
3883
- const maxDays = field.maxDays ?? 3650;
3957
+ const maxDays = effectiveMaxDays(field);
3958
+ const [daysTextValid, setDaysTextValid] = React24.useState(true);
3884
3959
  const [kind, setKind] = React24.useState(value?.kind ?? "withinDays");
3885
3960
  const [daysDraft, setDaysDraft] = React24.useState(
3886
3961
  value?.kind === "withinDays" ? value.days : defaultDays
@@ -3901,9 +3976,12 @@ function DatePanel({
3901
3976
  setKind(next);
3902
3977
  if (next === "between") setBetween({ from: "", to: "" });
3903
3978
  else setDaysDraft(defaultDays);
3979
+ setDaysTextValid(true);
3904
3980
  onCommit(void 0);
3905
3981
  };
3982
+ const doneDisabled = kind === "withinDays" && !daysTextValid;
3906
3983
  const confirm = () => {
3984
+ if (doneDisabled) return;
3907
3985
  if (kind === "withinDays" && daysDraft != null) onCommit({ kind: "withinDays", days: daysDraft });
3908
3986
  onClose();
3909
3987
  };
@@ -3917,7 +3995,7 @@ function DatePanel({
3917
3995
  offset: 6,
3918
3996
  width: PANEL_WIDTH.date,
3919
3997
  label: field.label,
3920
- 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" }),
3921
3999
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("span", { className: "fd-stack fd-gap-sm", children: [
3922
4000
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3923
4001
  SegmentedControl,
@@ -3931,7 +4009,19 @@ function DatePanel({
3931
4009
  onChange: (k) => switchKind(k)
3932
4010
  }
3933
4011
  ),
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)(
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)(
3935
4025
  DatePicker,
3936
4026
  {
3937
4027
  range: true,
@@ -3951,19 +4041,31 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3951
4041
  const textInputRef = React24.useRef(null);
3952
4042
  const optionsRef = React24.useRef(null);
3953
4043
  if (process.env.NODE_ENV !== "production") {
3954
- for (const f of fields) if (f.type === "date") assertValidDateField(f);
4044
+ for (const f of fields) assertValidFieldDefinition(f);
3955
4045
  }
4046
+ const latest = React24.useRef({ fields, filters, onFilterChange, onFilterRemove });
4047
+ latest.current = { fields, filters, onFilterChange, onFilterRemove };
3956
4048
  const commit = (field, candidate) => {
3957
- if (isEmptyCandidate(field, candidate)) {
3958
- removeField(field);
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);
3959
4053
  return { ok: true };
3960
4054
  }
3961
- const result = validateFilterValue(field, candidate);
3962
- if (result.ok) onFilterChange(field.key, candidate);
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
+ }
3963
4060
  return result;
3964
4061
  };
3965
4062
  const removeField = (field) => {
3966
- if (isFieldSet(field, filters[field.key])) onFilterRemove(field.key);
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);
3967
4069
  };
3968
4070
  const editingField = panel && panel.kind === "edit" ? fields.find((f) => f.key === panel.key) : void 0;
3969
4071
  const startEditing = (key) => setPanel({ kind: "edit", key });
@@ -13958,6 +14060,7 @@ function useDocumentTitle(routeTitle, appName) {
13958
14060
  useServerTable,
13959
14061
  useSessionContext,
13960
14062
  useStagedFiles,
14063
+ validateFilterValue,
13961
14064
  visibleMessages
13962
14065
  });
13963
14066
  //# sourceMappingURL=index.cjs.map