@flytedan/flytebot-design-system 0.30.0 → 0.31.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.d.cts CHANGED
@@ -1138,6 +1138,10 @@ interface FilterFieldRange extends FilterFieldBase {
1138
1138
  min?: number;
1139
1139
  max?: number;
1140
1140
  step?: number;
1141
+ /** Requires whole numbers for both bounds. Off by default, the same opt-in NumberInput
1142
+ * itself takes — a range field with a whole-number step (e.g. 10) may still want
1143
+ * decimal bounds, so this is a deliberate choice, not inferred from `step`. */
1144
+ integer?: boolean;
1141
1145
  minLabel?: string;
1142
1146
  maxLabel?: string;
1143
1147
  minPlaceholder?: string;
@@ -1154,11 +1158,15 @@ type FilterDateValue = {
1154
1158
  interface FilterFieldDate extends FilterFieldBase {
1155
1159
  type: "date";
1156
1160
  /** Default days shown the first time "within the last N days" is picked — shown, not
1157
- * written: nothing is set until the user changes it or confirms it. */
1161
+ * written: nothing is set until the user changes it or confirms it. Must be a whole
1162
+ * number from 1 to `maxDays`; FilterBar throws a clear, field-naming error in
1163
+ * development if it isn't. */
1158
1164
  defaultDays?: number;
1159
1165
  /** Upper bound enforced on the "within the last N days" input. Default 3650 (ten
1160
1166
  * years) — generous enough for any real "recent activity" window while still
1161
- * guaranteeing a plausible, in-range value ever reaches `onFilterChange`. */
1167
+ * guaranteeing a plausible, in-range value ever reaches `onFilterChange`. Must be a
1168
+ * whole number from 1 to 3650; FilterBar throws a clear, field-naming error in
1169
+ * development if it isn't. */
1162
1170
  maxDays?: number;
1163
1171
  }
1164
1172
  /** A declared filter field. Add a variant here (and a branch below) for a new value
@@ -2192,7 +2200,10 @@ interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement
2192
2200
  }
2193
2201
  declare function Textarea({ label, help, error, required, rows, disabled, id, className, style, ...rest }: TextareaProps): React.JSX.Element;
2194
2202
 
2195
- /** Numeric field with thousands formatting on blur, +/- steppers with hold-to-repeat acceleration, arrow keys (shift = bigStep), and min/max clamping. */
2203
+ /** Numeric field with thousands formatting on blur, +/- steppers with hold-to-repeat
2204
+ * acceleration and arrow keys (shift = bigStep) that snap to the step grid within
2205
+ * [min, max], and inline min/max/integer validation that keeps an invalid typed value
2206
+ * from ever reaching `onChange`. */
2196
2207
  interface NumberInputProps {
2197
2208
  label?: string;
2198
2209
  help?: string;
@@ -2214,6 +2225,12 @@ interface NumberInputProps {
2214
2225
  step?: number;
2215
2226
  /** Larger step used while holding the steppers or shift+arrows. */
2216
2227
  bigStep?: number;
2228
+ /** Requires a whole number. Off by default — a field with a whole-number `step` (e.g.
2229
+ * cents in steps of 10) may still want decimal input, so this is a deliberate opt-in
2230
+ * rather than inferred from `step`. When set, typed text, blur, the steppers and arrow
2231
+ * keys all reject or snap away from a fractional value; a fractional value can never
2232
+ * reach `onChange`. */
2233
+ integer?: boolean;
2217
2234
  /** Thousands-format the display when not editing. Default true. */
2218
2235
  format?: boolean;
2219
2236
  value?: number | string;
@@ -2222,11 +2239,17 @@ interface NumberInputProps {
2222
2239
  * (e.g. FilterBar's range/date panels) tells an unset bound apart from a typed
2223
2240
  * zero by this, not by testing for `0` itself. */
2224
2241
  onChange?: (e: any) => void;
2242
+ /** Called with the field's current validity every time it changes — true once the
2243
+ * typed text is empty or a legal number, false while it is out of bounds, non-integer
2244
+ * (when `integer` is set), or otherwise unparseable. Lets a parent (e.g. a panel with
2245
+ * its own Done button) know a value is mid-edit-and-invalid even though nothing has
2246
+ * been (or ever will be) passed to `onChange` for it. */
2247
+ onValidityChange?: (valid: boolean) => void;
2225
2248
  placeholder?: string;
2226
2249
  style?: any;
2227
2250
  className?: string;
2228
2251
  }
2229
- declare function NumberInput({ label, help, error, prefix, suffix, required, disabled, min, max, step, bigStep, value, onChange, format, className, style, placeholder, id, "aria-label": ariaLabel }: NumberInputProps): React.JSX.Element;
2252
+ declare function NumberInput({ label, help, error, prefix, suffix, required, disabled, min, max, step, bigStep, integer, value, onChange, onValidityChange, format, className, style, placeholder, id, "aria-label": ariaLabel }: NumberInputProps): React.JSX.Element;
2230
2253
 
2231
2254
  /** Rich popover select: animated listbox, keyboard nav + type-ahead, search over 8 options, option icons, descriptions, meta and groups. API-compatible with the old native select. */
2232
2255
  interface SelectOption {
package/dist/index.d.ts CHANGED
@@ -1138,6 +1138,10 @@ interface FilterFieldRange extends FilterFieldBase {
1138
1138
  min?: number;
1139
1139
  max?: number;
1140
1140
  step?: number;
1141
+ /** Requires whole numbers for both bounds. Off by default, the same opt-in NumberInput
1142
+ * itself takes — a range field with a whole-number step (e.g. 10) may still want
1143
+ * decimal bounds, so this is a deliberate choice, not inferred from `step`. */
1144
+ integer?: boolean;
1141
1145
  minLabel?: string;
1142
1146
  maxLabel?: string;
1143
1147
  minPlaceholder?: string;
@@ -1154,11 +1158,15 @@ type FilterDateValue = {
1154
1158
  interface FilterFieldDate extends FilterFieldBase {
1155
1159
  type: "date";
1156
1160
  /** Default days shown the first time "within the last N days" is picked — shown, not
1157
- * written: nothing is set until the user changes it or confirms it. */
1161
+ * written: nothing is set until the user changes it or confirms it. Must be a whole
1162
+ * number from 1 to `maxDays`; FilterBar throws a clear, field-naming error in
1163
+ * development if it isn't. */
1158
1164
  defaultDays?: number;
1159
1165
  /** Upper bound enforced on the "within the last N days" input. Default 3650 (ten
1160
1166
  * years) — generous enough for any real "recent activity" window while still
1161
- * guaranteeing a plausible, in-range value ever reaches `onFilterChange`. */
1167
+ * guaranteeing a plausible, in-range value ever reaches `onFilterChange`. Must be a
1168
+ * whole number from 1 to 3650; FilterBar throws a clear, field-naming error in
1169
+ * development if it isn't. */
1162
1170
  maxDays?: number;
1163
1171
  }
1164
1172
  /** A declared filter field. Add a variant here (and a branch below) for a new value
@@ -2192,7 +2200,10 @@ interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement
2192
2200
  }
2193
2201
  declare function Textarea({ label, help, error, required, rows, disabled, id, className, style, ...rest }: TextareaProps): React.JSX.Element;
2194
2202
 
2195
- /** Numeric field with thousands formatting on blur, +/- steppers with hold-to-repeat acceleration, arrow keys (shift = bigStep), and min/max clamping. */
2203
+ /** Numeric field with thousands formatting on blur, +/- steppers with hold-to-repeat
2204
+ * acceleration and arrow keys (shift = bigStep) that snap to the step grid within
2205
+ * [min, max], and inline min/max/integer validation that keeps an invalid typed value
2206
+ * from ever reaching `onChange`. */
2196
2207
  interface NumberInputProps {
2197
2208
  label?: string;
2198
2209
  help?: string;
@@ -2214,6 +2225,12 @@ interface NumberInputProps {
2214
2225
  step?: number;
2215
2226
  /** Larger step used while holding the steppers or shift+arrows. */
2216
2227
  bigStep?: number;
2228
+ /** Requires a whole number. Off by default — a field with a whole-number `step` (e.g.
2229
+ * cents in steps of 10) may still want decimal input, so this is a deliberate opt-in
2230
+ * rather than inferred from `step`. When set, typed text, blur, the steppers and arrow
2231
+ * keys all reject or snap away from a fractional value; a fractional value can never
2232
+ * reach `onChange`. */
2233
+ integer?: boolean;
2217
2234
  /** Thousands-format the display when not editing. Default true. */
2218
2235
  format?: boolean;
2219
2236
  value?: number | string;
@@ -2222,11 +2239,17 @@ interface NumberInputProps {
2222
2239
  * (e.g. FilterBar's range/date panels) tells an unset bound apart from a typed
2223
2240
  * zero by this, not by testing for `0` itself. */
2224
2241
  onChange?: (e: any) => void;
2242
+ /** Called with the field's current validity every time it changes — true once the
2243
+ * typed text is empty or a legal number, false while it is out of bounds, non-integer
2244
+ * (when `integer` is set), or otherwise unparseable. Lets a parent (e.g. a panel with
2245
+ * its own Done button) know a value is mid-edit-and-invalid even though nothing has
2246
+ * been (or ever will be) passed to `onChange` for it. */
2247
+ onValidityChange?: (valid: boolean) => void;
2225
2248
  placeholder?: string;
2226
2249
  style?: any;
2227
2250
  className?: string;
2228
2251
  }
2229
- declare function NumberInput({ label, help, error, prefix, suffix, required, disabled, min, max, step, bigStep, value, onChange, format, className, style, placeholder, id, "aria-label": ariaLabel }: NumberInputProps): React.JSX.Element;
2252
+ declare function NumberInput({ label, help, error, prefix, suffix, required, disabled, min, max, step, bigStep, integer, value, onChange, onValidityChange, format, className, style, placeholder, id, "aria-label": ariaLabel }: NumberInputProps): React.JSX.Element;
2230
2253
 
2231
2254
  /** Rich popover select: animated listbox, keyboard nav + type-ahead, search over 8 options, option icons, descriptions, meta and groups. API-compatible with the old native select. */
2232
2255
  interface SelectOption {
package/dist/index.js CHANGED
@@ -2769,8 +2769,10 @@ function NumberInput({
2769
2769
  max = Infinity,
2770
2770
  step = 1,
2771
2771
  bigStep,
2772
+ integer = false,
2772
2773
  value,
2773
2774
  onChange,
2775
+ onValidityChange,
2774
2776
  format = true,
2775
2777
  className = "",
2776
2778
  style,
@@ -2786,9 +2788,8 @@ function NumberInput({
2786
2788
  const n = Number(s);
2787
2789
  return isNaN(n) ? null : n;
2788
2790
  };
2789
- const requiresInteger = Number.isInteger(step);
2790
2791
  const validationMessage = (n) => {
2791
- if (requiresInteger && !Number.isInteger(n)) return "Enter a whole number";
2792
+ if (integer && !Number.isInteger(n)) return "Enter a whole number";
2792
2793
  if (n < min) return "Must be \u2265 " + min;
2793
2794
  if (n > max) return "Must be \u2264 " + max;
2794
2795
  return null;
@@ -2803,11 +2804,17 @@ function NumberInput({
2803
2804
  setInvalidMessage(null);
2804
2805
  }
2805
2806
  }, [value, editing]);
2807
+ React21.useEffect(() => {
2808
+ onValidityChange && onValidityChange(invalidMessage == null);
2809
+ }, [invalidMessage]);
2806
2810
  const clamp = (n) => Math.min(max, Math.max(min, n));
2807
2811
  const fire = (n) => onChange && onChange({ target: { value: n } });
2808
2812
  const bump = (dir, mult = 1) => {
2809
- const cur = num(text) ?? num(value) ?? (min === -Infinity ? 0 : min);
2810
- const next = clamp(cur + dir * (bigStep && mult > 1 ? bigStep : step) * (bigStep ? 1 : mult));
2813
+ const cur = (invalidMessage ? null : num(text)) ?? num(value) ?? (min === -Infinity ? 0 : min);
2814
+ const amount = (bigStep && mult > 1 ? bigStep : step) * (bigStep ? 1 : mult);
2815
+ let next = cur + dir * amount;
2816
+ if (integer) next = Math.round(next);
2817
+ next = clamp(next);
2811
2818
  setInvalidMessage(null);
2812
2819
  setText(String(next));
2813
2820
  fire(next);
@@ -3194,6 +3201,91 @@ function isFieldSet(field, value) {
3194
3201
  }
3195
3202
  }
3196
3203
  }
3204
+ function validateFilterValue(field, value) {
3205
+ switch (field.type) {
3206
+ case "text":
3207
+ case "select":
3208
+ return { ok: true };
3209
+ case "multiSelect": {
3210
+ const v = value;
3211
+ if (!v || !Array.isArray(v.values) || v.values.length === 0) {
3212
+ return { ok: false, reason: "Select at least one value" };
3213
+ }
3214
+ if (!operatorsFor(field).includes(v.operator)) {
3215
+ return { ok: false, reason: "That operator isn't allowed for " + field.label };
3216
+ }
3217
+ return { ok: true };
3218
+ }
3219
+ case "range": {
3220
+ const v = value;
3221
+ if (!v || v.min == null && v.max == null) {
3222
+ return { ok: false, reason: "Enter at least one bound" };
3223
+ }
3224
+ const lo = field.min ?? -Infinity;
3225
+ const hi = field.max ?? Infinity;
3226
+ for (const n of [v.min, v.max]) {
3227
+ if (n == null) continue;
3228
+ if (field.integer && !Number.isInteger(n)) return { ok: false, reason: "Enter a whole number" };
3229
+ if (n < lo) return { ok: false, reason: "Must be \u2265 " + lo };
3230
+ if (n > hi) return { ok: false, reason: "Must be \u2264 " + hi };
3231
+ }
3232
+ if (v.min != null && v.max != null && v.min > v.max) {
3233
+ return { ok: false, reason: "Must be \u2265 " + v.min };
3234
+ }
3235
+ return { ok: true };
3236
+ }
3237
+ case "date": {
3238
+ const v = value;
3239
+ if (!v) return { ok: false, reason: "Pick a date value" };
3240
+ if (v.kind === "withinDays") {
3241
+ const maxDays = field.maxDays ?? 3650;
3242
+ if (v.days == null) return { ok: false, reason: "Enter a number of days" };
3243
+ if (!Number.isInteger(v.days)) return { ok: false, reason: "Enter a whole number" };
3244
+ if (v.days < 1) return { ok: false, reason: "Must be \u2265 1" };
3245
+ if (v.days > maxDays) return { ok: false, reason: "Must be \u2264 " + maxDays };
3246
+ return { ok: true };
3247
+ }
3248
+ if (!v.from || !v.to) return { ok: false, reason: "Pick both dates" };
3249
+ if (v.from > v.to) return { ok: false, reason: "The start date must be on or before the end date" };
3250
+ return { ok: true };
3251
+ }
3252
+ }
3253
+ }
3254
+ function isEmptyCandidate(field, candidate) {
3255
+ switch (field.type) {
3256
+ case "text":
3257
+ case "select":
3258
+ return false;
3259
+ case "multiSelect": {
3260
+ const v = candidate;
3261
+ return !v || !Array.isArray(v.values) || v.values.length === 0;
3262
+ }
3263
+ case "range": {
3264
+ const v = candidate;
3265
+ return !v || v.min == null && v.max == null;
3266
+ }
3267
+ case "date": {
3268
+ const v = candidate;
3269
+ if (!v) return true;
3270
+ return v.kind === "withinDays" ? v.days == null : !(v.from && v.to);
3271
+ }
3272
+ }
3273
+ }
3274
+ function assertValidDateField(field) {
3275
+ if (process.env.NODE_ENV === "production") return;
3276
+ const defaultDays = field.defaultDays ?? 30;
3277
+ const maxDays = field.maxDays ?? 3650;
3278
+ if (!Number.isInteger(maxDays) || maxDays < 1 || maxDays > 3650) {
3279
+ throw new Error(
3280
+ 'FilterBar: date field "' + field.key + '" has an invalid maxDays (' + field.maxDays + ") \u2014 must be a whole number from 1 to 3650."
3281
+ );
3282
+ }
3283
+ if (!Number.isInteger(defaultDays) || defaultDays < 1 || defaultDays > maxDays) {
3284
+ throw new Error(
3285
+ 'FilterBar: date field "' + field.key + '" has an invalid defaultDays (' + field.defaultDays + ") \u2014 must be a whole number from 1 to maxDays (" + maxDays + ")."
3286
+ );
3287
+ }
3288
+ }
3197
3289
  function chipLabel(field, value) {
3198
3290
  switch (field.type) {
3199
3291
  case "text":
@@ -3246,8 +3338,7 @@ function MultiSelectPanel({
3246
3338
  field,
3247
3339
  value,
3248
3340
  anchorRef,
3249
- onChange,
3250
- onRemove,
3341
+ onCommit,
3251
3342
  onClose
3252
3343
  }) {
3253
3344
  const allowedOps = operatorsFor(field);
@@ -3256,9 +3347,17 @@ function MultiSelectPanel({
3256
3347
  const selected = value?.values ?? [];
3257
3348
  const selectedRef = React24.useRef(selected);
3258
3349
  selectedRef.current = selected;
3350
+ const operatorRef = React24.useRef(operator);
3351
+ operatorRef.current = operator;
3352
+ const mountedRef = React24.useRef(true);
3353
+ React24.useEffect(() => {
3354
+ mountedRef.current = true;
3355
+ return () => {
3356
+ mountedRef.current = false;
3357
+ };
3358
+ }, []);
3259
3359
  const commit = (nextValues, nextOperator = operator) => {
3260
- if (nextValues.length === 0) onRemove();
3261
- else onChange({ operator: nextOperator, values: nextValues });
3360
+ onCommit({ operator: nextOperator, values: nextValues });
3262
3361
  };
3263
3362
  const setOperator = (op) => {
3264
3363
  setDraftOperator(op);
@@ -3329,13 +3428,15 @@ function MultiSelectPanel({
3329
3428
  setPasteLoading(true);
3330
3429
  setPasteError(null);
3331
3430
  field.resolve(tokens).then((result) => {
3431
+ if (!mountedRef.current) return;
3332
3432
  setPasteLoading(false);
3333
3433
  setUnmatchedTokens(result.unmatched);
3334
3434
  const merged = new Map(selectedRef.current.map((s) => [s.value, s]));
3335
3435
  for (const m of result.matched) merged.set(m.value, { value: m.value, label: m.label });
3336
- commit(Array.from(merged.values()));
3436
+ commit(Array.from(merged.values()), operatorRef.current);
3337
3437
  setPasteText("");
3338
3438
  }).catch((err) => {
3439
+ if (!mountedRef.current) return;
3339
3440
  setPasteLoading(false);
3340
3441
  setPasteError(err instanceof Error ? err.message : "Could not resolve the pasted list.");
3341
3442
  });
@@ -3471,22 +3572,21 @@ function RangePanel({
3471
3572
  field,
3472
3573
  value,
3473
3574
  anchorRef,
3474
- onChange,
3475
- onRemove,
3575
+ onCommit,
3476
3576
  onClose
3477
3577
  }) {
3478
3578
  const [draftMin, setDraftMin] = React24.useState(value?.min);
3479
3579
  const [draftMax, setDraftMax] = React24.useState(value?.max);
3480
- const invalid = draftMin != null && draftMax != null && draftMin > draftMax;
3580
+ const [minTextValid, setMinTextValid] = React24.useState(true);
3581
+ const [maxTextValid, setMaxTextValid] = React24.useState(true);
3582
+ const isEmpty = draftMin == null && draftMax == null;
3583
+ const validation = isEmpty ? { ok: true } : validateFilterValue(field, { min: draftMin, max: draftMax });
3584
+ const comboError = !isEmpty && !validation.ok ? validation.reason : void 0;
3585
+ const doneDisabled = !minTextValid || !maxTextValid || !!comboError;
3481
3586
  const apply = (nextMin, nextMax) => {
3482
3587
  setDraftMin(nextMin);
3483
3588
  setDraftMax(nextMax);
3484
- if (nextMin == null && nextMax == null) {
3485
- onRemove();
3486
- return;
3487
- }
3488
- if (nextMin != null && nextMax != null && nextMin > nextMax) return;
3489
- onChange({ min: nextMin, max: nextMax });
3589
+ onCommit(nextMin == null && nextMax == null ? void 0 : { min: nextMin, max: nextMax });
3490
3590
  };
3491
3591
  return /* @__PURE__ */ jsx45(
3492
3592
  Popover,
@@ -3498,7 +3598,7 @@ function RangePanel({
3498
3598
  offset: 6,
3499
3599
  width: PANEL_WIDTH.range,
3500
3600
  label: field.label,
3501
- footer: /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: onClose, disabled: invalid, children: "Done" }),
3601
+ footer: /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: onClose, disabled: doneDisabled, children: "Done" }),
3502
3602
  children: /* @__PURE__ */ jsxs39("span", { className: "fd-stack fd-gap-sm", children: [
3503
3603
  /* @__PURE__ */ jsx45(
3504
3604
  NumberInput,
@@ -3508,8 +3608,10 @@ function RangePanel({
3508
3608
  min: field.min,
3509
3609
  max: field.max,
3510
3610
  step: field.step,
3611
+ integer: field.integer,
3511
3612
  placeholder: field.minPlaceholder,
3512
- onChange: (e) => apply(e.target.value, draftMax)
3613
+ onChange: (e) => apply(e.target.value, draftMax),
3614
+ onValidityChange: setMinTextValid
3513
3615
  }
3514
3616
  ),
3515
3617
  /* @__PURE__ */ jsx45(
@@ -3520,9 +3622,11 @@ function RangePanel({
3520
3622
  min: field.min,
3521
3623
  max: field.max,
3522
3624
  step: field.step,
3523
- error: invalid ? "Must be \u2265 " + draftMin : void 0,
3625
+ integer: field.integer,
3626
+ error: comboError,
3524
3627
  placeholder: field.maxPlaceholder,
3525
- onChange: (e) => apply(draftMin, e.target.value)
3628
+ onChange: (e) => apply(draftMin, e.target.value),
3629
+ onValidityChange: setMaxTextValid
3526
3630
  }
3527
3631
  )
3528
3632
  ] })
@@ -3533,8 +3637,7 @@ function DatePanel({
3533
3637
  field,
3534
3638
  value,
3535
3639
  anchorRef,
3536
- onChange,
3537
- onRemove,
3640
+ onCommit,
3538
3641
  onClose
3539
3642
  }) {
3540
3643
  const defaultDays = field.defaultDays ?? 30;
@@ -3546,42 +3649,23 @@ function DatePanel({
3546
3649
  const [between, setBetween] = React24.useState(
3547
3650
  value?.kind === "between" ? { from: value.from, to: value.to } : { from: "", to: "" }
3548
3651
  );
3549
- const committedRef = React24.useRef(!!value);
3550
3652
  const applyWithinDays = (days) => {
3551
3653
  setDaysDraft(days);
3552
- if (days != null) {
3553
- committedRef.current = true;
3554
- onChange({ kind: "withinDays", days });
3555
- } else if (committedRef.current) {
3556
- committedRef.current = false;
3557
- onRemove();
3558
- }
3654
+ onCommit(days == null ? void 0 : { kind: "withinDays", days });
3559
3655
  };
3560
3656
  const applyBetween = (from, to) => {
3561
3657
  setBetween({ from, to });
3562
- if (from && to) {
3563
- committedRef.current = true;
3564
- onChange({ kind: "between", from, to });
3565
- } else if (committedRef.current) {
3566
- committedRef.current = false;
3567
- onRemove();
3568
- }
3658
+ onCommit(from && to ? { kind: "between", from, to } : void 0);
3569
3659
  };
3570
3660
  const switchKind = (next) => {
3661
+ if (next === kind) return;
3571
3662
  setKind(next);
3572
- if (next === "between") {
3573
- setBetween({ from: "", to: "" });
3574
- if (committedRef.current) {
3575
- committedRef.current = false;
3576
- onRemove();
3577
- }
3578
- }
3663
+ if (next === "between") setBetween({ from: "", to: "" });
3664
+ else setDaysDraft(defaultDays);
3665
+ onCommit(void 0);
3579
3666
  };
3580
3667
  const confirm = () => {
3581
- if (kind === "withinDays" && daysDraft != null) {
3582
- committedRef.current = true;
3583
- onChange({ kind: "withinDays", days: daysDraft });
3584
- }
3668
+ if (kind === "withinDays" && daysDraft != null) onCommit({ kind: "withinDays", days: daysDraft });
3585
3669
  onClose();
3586
3670
  };
3587
3671
  return /* @__PURE__ */ jsx45(
@@ -3608,7 +3692,7 @@ function DatePanel({
3608
3692
  onChange: (k) => switchKind(k)
3609
3693
  }
3610
3694
  ),
3611
- kind === "withinDays" ? /* @__PURE__ */ jsx45(NumberInput, { label: "Days", min: 1, max: maxDays, step: 1, value: daysDraft ?? "", onChange: (e) => applyWithinDays(e.target.value) }) : /* @__PURE__ */ jsx45(
3695
+ kind === "withinDays" ? /* @__PURE__ */ jsx45(NumberInput, { label: "Days", min: 1, max: maxDays, step: 1, integer: true, value: daysDraft ?? "", onChange: (e) => applyWithinDays(e.target.value) }) : /* @__PURE__ */ jsx45(
3612
3696
  DatePicker,
3613
3697
  {
3614
3698
  range: true,
@@ -3627,6 +3711,21 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3627
3711
  const addAnchorRef = React24.useRef(null);
3628
3712
  const textInputRef = React24.useRef(null);
3629
3713
  const optionsRef = React24.useRef(null);
3714
+ if (process.env.NODE_ENV !== "production") {
3715
+ for (const f of fields) if (f.type === "date") assertValidDateField(f);
3716
+ }
3717
+ const commit = (field, candidate) => {
3718
+ if (isEmptyCandidate(field, candidate)) {
3719
+ removeField(field);
3720
+ return { ok: true };
3721
+ }
3722
+ const result = validateFilterValue(field, candidate);
3723
+ if (result.ok) onFilterChange(field.key, candidate);
3724
+ return result;
3725
+ };
3726
+ const removeField = (field) => {
3727
+ if (isFieldSet(field, filters[field.key])) onFilterRemove(field.key);
3728
+ };
3630
3729
  const editingField = panel && panel.kind === "edit" ? fields.find((f) => f.key === panel.key) : void 0;
3631
3730
  const startEditing = (key) => setPanel({ kind: "edit", key });
3632
3731
  const closeEditing = () => setPanel(null);
@@ -3658,7 +3757,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3658
3757
  value: value ?? "",
3659
3758
  placeholder: field.placeholder || field.label,
3660
3759
  "aria-label": field.label,
3661
- onChange: (e) => onFilterChange(field.key, e.target.value),
3760
+ onChange: (e) => commit(field, e.target.value),
3662
3761
  onKeyDown: (e) => {
3663
3762
  if (e.key === "Enter") setPanel(null);
3664
3763
  else if (e.key === "Escape") {
@@ -3677,7 +3776,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3677
3776
  "aria-label": "Remove " + field.label,
3678
3777
  onClick: (e) => {
3679
3778
  e.stopPropagation();
3680
- onFilterRemove(field.key);
3779
+ removeField(field);
3681
3780
  setPanel(null);
3682
3781
  },
3683
3782
  children: /* @__PURE__ */ jsx45("i", { className: "ph ph-x", "aria-hidden": "true" })
@@ -3690,7 +3789,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3690
3789
  const chipRef = editing ? editAnchorRef : void 0;
3691
3790
  const toggleOrClose = () => editing ? closeEditing() : startEditing(field.key);
3692
3791
  const remove = () => {
3693
- if (set) onFilterRemove(field.key);
3792
+ removeField(field);
3694
3793
  closeEditing();
3695
3794
  };
3696
3795
  const chipText = field.label + (set ? ": " + chipLabel(field, value) : "");
@@ -3720,7 +3819,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3720
3819
  "aria-selected": on,
3721
3820
  className: ["fd-opt", on ? "is-selected" : ""].filter(Boolean).join(" "),
3722
3821
  onClick: () => {
3723
- onFilterChange(field.key, o.value);
3822
+ commit(field, o.value);
3724
3823
  setPanel(null);
3725
3824
  },
3726
3825
  children: /* @__PURE__ */ jsx45("span", { className: "fd-opt-label", children: o.label })
@@ -3741,8 +3840,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3741
3840
  field,
3742
3841
  value,
3743
3842
  anchorRef: editAnchorRef,
3744
- onChange: (v) => onFilterChange(field.key, v),
3745
- onRemove: () => onFilterRemove(field.key),
3843
+ onCommit: (v) => commit(field, v),
3746
3844
  onClose: closeEditing
3747
3845
  }
3748
3846
  ) : null
@@ -3757,8 +3855,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3757
3855
  field,
3758
3856
  value,
3759
3857
  anchorRef: editAnchorRef,
3760
- onChange: (v) => onFilterChange(field.key, v),
3761
- onRemove: () => onFilterRemove(field.key),
3858
+ onCommit: (v) => commit(field, v),
3762
3859
  onClose: closeEditing
3763
3860
  }
3764
3861
  ) : null
@@ -3773,8 +3870,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3773
3870
  field,
3774
3871
  value,
3775
3872
  anchorRef: editAnchorRef,
3776
- onChange: (v) => onFilterChange(field.key, v),
3777
- onRemove: () => onFilterRemove(field.key),
3873
+ onCommit: (v) => commit(field, v),
3778
3874
  onClose: closeEditing
3779
3875
  }
3780
3876
  ) : null