@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.cjs CHANGED
@@ -3008,8 +3008,10 @@ function NumberInput({
3008
3008
  max = Infinity,
3009
3009
  step = 1,
3010
3010
  bigStep,
3011
+ integer = false,
3011
3012
  value,
3012
3013
  onChange,
3014
+ onValidityChange,
3013
3015
  format = true,
3014
3016
  className = "",
3015
3017
  style,
@@ -3025,9 +3027,8 @@ function NumberInput({
3025
3027
  const n = Number(s);
3026
3028
  return isNaN(n) ? null : n;
3027
3029
  };
3028
- const requiresInteger = Number.isInteger(step);
3029
3030
  const validationMessage = (n) => {
3030
- if (requiresInteger && !Number.isInteger(n)) return "Enter a whole number";
3031
+ if (integer && !Number.isInteger(n)) return "Enter a whole number";
3031
3032
  if (n < min) return "Must be \u2265 " + min;
3032
3033
  if (n > max) return "Must be \u2264 " + max;
3033
3034
  return null;
@@ -3042,11 +3043,17 @@ function NumberInput({
3042
3043
  setInvalidMessage(null);
3043
3044
  }
3044
3045
  }, [value, editing]);
3046
+ React21.useEffect(() => {
3047
+ onValidityChange && onValidityChange(invalidMessage == null);
3048
+ }, [invalidMessage]);
3045
3049
  const clamp = (n) => Math.min(max, Math.max(min, n));
3046
3050
  const fire = (n) => onChange && onChange({ target: { value: n } });
3047
3051
  const bump = (dir, mult = 1) => {
3048
- const cur = num(text) ?? num(value) ?? (min === -Infinity ? 0 : min);
3049
- const next = clamp(cur + dir * (bigStep && mult > 1 ? bigStep : step) * (bigStep ? 1 : mult));
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);
3050
3057
  setInvalidMessage(null);
3051
3058
  setText(String(next));
3052
3059
  fire(next);
@@ -3433,6 +3440,91 @@ function isFieldSet(field, value) {
3433
3440
  }
3434
3441
  }
3435
3442
  }
3443
+ function validateFilterValue(field, value) {
3444
+ switch (field.type) {
3445
+ case "text":
3446
+ case "select":
3447
+ return { ok: true };
3448
+ case "multiSelect": {
3449
+ const v = value;
3450
+ if (!v || !Array.isArray(v.values) || v.values.length === 0) {
3451
+ return { ok: false, reason: "Select at least one value" };
3452
+ }
3453
+ if (!operatorsFor(field).includes(v.operator)) {
3454
+ return { ok: false, reason: "That operator isn't allowed for " + field.label };
3455
+ }
3456
+ return { ok: true };
3457
+ }
3458
+ case "range": {
3459
+ const v = value;
3460
+ if (!v || v.min == null && v.max == null) {
3461
+ return { ok: false, reason: "Enter at least one bound" };
3462
+ }
3463
+ const lo = field.min ?? -Infinity;
3464
+ const hi = field.max ?? Infinity;
3465
+ for (const n of [v.min, v.max]) {
3466
+ if (n == null) continue;
3467
+ if (field.integer && !Number.isInteger(n)) return { ok: false, reason: "Enter a whole number" };
3468
+ if (n < lo) return { ok: false, reason: "Must be \u2265 " + lo };
3469
+ if (n > hi) return { ok: false, reason: "Must be \u2264 " + hi };
3470
+ }
3471
+ if (v.min != null && v.max != null && v.min > v.max) {
3472
+ return { ok: false, reason: "Must be \u2265 " + v.min };
3473
+ }
3474
+ return { ok: true };
3475
+ }
3476
+ case "date": {
3477
+ const v = value;
3478
+ if (!v) return { ok: false, reason: "Pick a date value" };
3479
+ if (v.kind === "withinDays") {
3480
+ const maxDays = field.maxDays ?? 3650;
3481
+ if (v.days == null) return { ok: false, reason: "Enter a number of days" };
3482
+ if (!Number.isInteger(v.days)) return { ok: false, reason: "Enter a whole number" };
3483
+ if (v.days < 1) return { ok: false, reason: "Must be \u2265 1" };
3484
+ if (v.days > maxDays) return { ok: false, reason: "Must be \u2264 " + maxDays };
3485
+ return { ok: true };
3486
+ }
3487
+ if (!v.from || !v.to) return { ok: false, reason: "Pick both dates" };
3488
+ if (v.from > v.to) return { ok: false, reason: "The start date must be on or before the end date" };
3489
+ return { ok: true };
3490
+ }
3491
+ }
3492
+ }
3493
+ 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
+ }
3512
+ }
3513
+ function assertValidDateField(field) {
3514
+ if (process.env.NODE_ENV === "production") return;
3515
+ const defaultDays = field.defaultDays ?? 30;
3516
+ const maxDays = field.maxDays ?? 3650;
3517
+ if (!Number.isInteger(maxDays) || maxDays < 1 || maxDays > 3650) {
3518
+ 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."
3520
+ );
3521
+ }
3522
+ if (!Number.isInteger(defaultDays) || defaultDays < 1 || defaultDays > maxDays) {
3523
+ throw new Error(
3524
+ 'FilterBar: date field "' + field.key + '" has an invalid defaultDays (' + field.defaultDays + ") \u2014 must be a whole number from 1 to maxDays (" + maxDays + ")."
3525
+ );
3526
+ }
3527
+ }
3436
3528
  function chipLabel(field, value) {
3437
3529
  switch (field.type) {
3438
3530
  case "text":
@@ -3485,8 +3577,7 @@ function MultiSelectPanel({
3485
3577
  field,
3486
3578
  value,
3487
3579
  anchorRef,
3488
- onChange,
3489
- onRemove,
3580
+ onCommit,
3490
3581
  onClose
3491
3582
  }) {
3492
3583
  const allowedOps = operatorsFor(field);
@@ -3495,9 +3586,17 @@ function MultiSelectPanel({
3495
3586
  const selected = value?.values ?? [];
3496
3587
  const selectedRef = React24.useRef(selected);
3497
3588
  selectedRef.current = selected;
3589
+ const operatorRef = React24.useRef(operator);
3590
+ operatorRef.current = operator;
3591
+ const mountedRef = React24.useRef(true);
3592
+ React24.useEffect(() => {
3593
+ mountedRef.current = true;
3594
+ return () => {
3595
+ mountedRef.current = false;
3596
+ };
3597
+ }, []);
3498
3598
  const commit = (nextValues, nextOperator = operator) => {
3499
- if (nextValues.length === 0) onRemove();
3500
- else onChange({ operator: nextOperator, values: nextValues });
3599
+ onCommit({ operator: nextOperator, values: nextValues });
3501
3600
  };
3502
3601
  const setOperator = (op) => {
3503
3602
  setDraftOperator(op);
@@ -3568,13 +3667,15 @@ function MultiSelectPanel({
3568
3667
  setPasteLoading(true);
3569
3668
  setPasteError(null);
3570
3669
  field.resolve(tokens).then((result) => {
3670
+ if (!mountedRef.current) return;
3571
3671
  setPasteLoading(false);
3572
3672
  setUnmatchedTokens(result.unmatched);
3573
3673
  const merged = new Map(selectedRef.current.map((s) => [s.value, s]));
3574
3674
  for (const m of result.matched) merged.set(m.value, { value: m.value, label: m.label });
3575
- commit(Array.from(merged.values()));
3675
+ commit(Array.from(merged.values()), operatorRef.current);
3576
3676
  setPasteText("");
3577
3677
  }).catch((err) => {
3678
+ if (!mountedRef.current) return;
3578
3679
  setPasteLoading(false);
3579
3680
  setPasteError(err instanceof Error ? err.message : "Could not resolve the pasted list.");
3580
3681
  });
@@ -3710,22 +3811,21 @@ function RangePanel({
3710
3811
  field,
3711
3812
  value,
3712
3813
  anchorRef,
3713
- onChange,
3714
- onRemove,
3814
+ onCommit,
3715
3815
  onClose
3716
3816
  }) {
3717
3817
  const [draftMin, setDraftMin] = React24.useState(value?.min);
3718
3818
  const [draftMax, setDraftMax] = React24.useState(value?.max);
3719
- const invalid = draftMin != null && draftMax != null && draftMin > draftMax;
3819
+ const [minTextValid, setMinTextValid] = React24.useState(true);
3820
+ const [maxTextValid, setMaxTextValid] = React24.useState(true);
3821
+ const isEmpty = draftMin == null && draftMax == null;
3822
+ const validation = isEmpty ? { ok: true } : validateFilterValue(field, { min: draftMin, max: draftMax });
3823
+ const comboError = !isEmpty && !validation.ok ? validation.reason : void 0;
3824
+ const doneDisabled = !minTextValid || !maxTextValid || !!comboError;
3720
3825
  const apply = (nextMin, nextMax) => {
3721
3826
  setDraftMin(nextMin);
3722
3827
  setDraftMax(nextMax);
3723
- if (nextMin == null && nextMax == null) {
3724
- onRemove();
3725
- return;
3726
- }
3727
- if (nextMin != null && nextMax != null && nextMin > nextMax) return;
3728
- onChange({ min: nextMin, max: nextMax });
3828
+ onCommit(nextMin == null && nextMax == null ? void 0 : { min: nextMin, max: nextMax });
3729
3829
  };
3730
3830
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3731
3831
  Popover,
@@ -3737,7 +3837,7 @@ function RangePanel({
3737
3837
  offset: 6,
3738
3838
  width: PANEL_WIDTH.range,
3739
3839
  label: field.label,
3740
- footer: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: onClose, disabled: invalid, children: "Done" }),
3840
+ footer: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(Button, { size: "sm", variant: "secondary", onClick: onClose, disabled: doneDisabled, children: "Done" }),
3741
3841
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsxs)("span", { className: "fd-stack fd-gap-sm", children: [
3742
3842
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
3743
3843
  NumberInput,
@@ -3747,8 +3847,10 @@ function RangePanel({
3747
3847
  min: field.min,
3748
3848
  max: field.max,
3749
3849
  step: field.step,
3850
+ integer: field.integer,
3750
3851
  placeholder: field.minPlaceholder,
3751
- onChange: (e) => apply(e.target.value, draftMax)
3852
+ onChange: (e) => apply(e.target.value, draftMax),
3853
+ onValidityChange: setMinTextValid
3752
3854
  }
3753
3855
  ),
3754
3856
  /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
@@ -3759,9 +3861,11 @@ function RangePanel({
3759
3861
  min: field.min,
3760
3862
  max: field.max,
3761
3863
  step: field.step,
3762
- error: invalid ? "Must be \u2265 " + draftMin : void 0,
3864
+ integer: field.integer,
3865
+ error: comboError,
3763
3866
  placeholder: field.maxPlaceholder,
3764
- onChange: (e) => apply(draftMin, e.target.value)
3867
+ onChange: (e) => apply(draftMin, e.target.value),
3868
+ onValidityChange: setMaxTextValid
3765
3869
  }
3766
3870
  )
3767
3871
  ] })
@@ -3772,8 +3876,7 @@ function DatePanel({
3772
3876
  field,
3773
3877
  value,
3774
3878
  anchorRef,
3775
- onChange,
3776
- onRemove,
3879
+ onCommit,
3777
3880
  onClose
3778
3881
  }) {
3779
3882
  const defaultDays = field.defaultDays ?? 30;
@@ -3785,42 +3888,23 @@ function DatePanel({
3785
3888
  const [between, setBetween] = React24.useState(
3786
3889
  value?.kind === "between" ? { from: value.from, to: value.to } : { from: "", to: "" }
3787
3890
  );
3788
- const committedRef = React24.useRef(!!value);
3789
3891
  const applyWithinDays = (days) => {
3790
3892
  setDaysDraft(days);
3791
- if (days != null) {
3792
- committedRef.current = true;
3793
- onChange({ kind: "withinDays", days });
3794
- } else if (committedRef.current) {
3795
- committedRef.current = false;
3796
- onRemove();
3797
- }
3893
+ onCommit(days == null ? void 0 : { kind: "withinDays", days });
3798
3894
  };
3799
3895
  const applyBetween = (from, to) => {
3800
3896
  setBetween({ from, to });
3801
- if (from && to) {
3802
- committedRef.current = true;
3803
- onChange({ kind: "between", from, to });
3804
- } else if (committedRef.current) {
3805
- committedRef.current = false;
3806
- onRemove();
3807
- }
3897
+ onCommit(from && to ? { kind: "between", from, to } : void 0);
3808
3898
  };
3809
3899
  const switchKind = (next) => {
3900
+ if (next === kind) return;
3810
3901
  setKind(next);
3811
- if (next === "between") {
3812
- setBetween({ from: "", to: "" });
3813
- if (committedRef.current) {
3814
- committedRef.current = false;
3815
- onRemove();
3816
- }
3817
- }
3902
+ if (next === "between") setBetween({ from: "", to: "" });
3903
+ else setDaysDraft(defaultDays);
3904
+ onCommit(void 0);
3818
3905
  };
3819
3906
  const confirm = () => {
3820
- if (kind === "withinDays" && daysDraft != null) {
3821
- committedRef.current = true;
3822
- onChange({ kind: "withinDays", days: daysDraft });
3823
- }
3907
+ if (kind === "withinDays" && daysDraft != null) onCommit({ kind: "withinDays", days: daysDraft });
3824
3908
  onClose();
3825
3909
  };
3826
3910
  return /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
@@ -3847,7 +3931,7 @@ function DatePanel({
3847
3931
  onChange: (k) => switchKind(k)
3848
3932
  }
3849
3933
  ),
3850
- kind === "withinDays" ? /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(NumberInput, { label: "Days", min: 1, max: maxDays, step: 1, value: daysDraft ?? "", onChange: (e) => applyWithinDays(e.target.value) }) : /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
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)(
3851
3935
  DatePicker,
3852
3936
  {
3853
3937
  range: true,
@@ -3866,6 +3950,21 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3866
3950
  const addAnchorRef = React24.useRef(null);
3867
3951
  const textInputRef = React24.useRef(null);
3868
3952
  const optionsRef = React24.useRef(null);
3953
+ if (process.env.NODE_ENV !== "production") {
3954
+ for (const f of fields) if (f.type === "date") assertValidDateField(f);
3955
+ }
3956
+ const commit = (field, candidate) => {
3957
+ if (isEmptyCandidate(field, candidate)) {
3958
+ removeField(field);
3959
+ return { ok: true };
3960
+ }
3961
+ const result = validateFilterValue(field, candidate);
3962
+ if (result.ok) onFilterChange(field.key, candidate);
3963
+ return result;
3964
+ };
3965
+ const removeField = (field) => {
3966
+ if (isFieldSet(field, filters[field.key])) onFilterRemove(field.key);
3967
+ };
3869
3968
  const editingField = panel && panel.kind === "edit" ? fields.find((f) => f.key === panel.key) : void 0;
3870
3969
  const startEditing = (key) => setPanel({ kind: "edit", key });
3871
3970
  const closeEditing = () => setPanel(null);
@@ -3897,7 +3996,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3897
3996
  value: value ?? "",
3898
3997
  placeholder: field.placeholder || field.label,
3899
3998
  "aria-label": field.label,
3900
- onChange: (e) => onFilterChange(field.key, e.target.value),
3999
+ onChange: (e) => commit(field, e.target.value),
3901
4000
  onKeyDown: (e) => {
3902
4001
  if (e.key === "Enter") setPanel(null);
3903
4002
  else if (e.key === "Escape") {
@@ -3916,7 +4015,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3916
4015
  "aria-label": "Remove " + field.label,
3917
4016
  onClick: (e) => {
3918
4017
  e.stopPropagation();
3919
- onFilterRemove(field.key);
4018
+ removeField(field);
3920
4019
  setPanel(null);
3921
4020
  },
3922
4021
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("i", { className: "ph ph-x", "aria-hidden": "true" })
@@ -3929,7 +4028,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3929
4028
  const chipRef = editing ? editAnchorRef : void 0;
3930
4029
  const toggleOrClose = () => editing ? closeEditing() : startEditing(field.key);
3931
4030
  const remove = () => {
3932
- if (set) onFilterRemove(field.key);
4031
+ removeField(field);
3933
4032
  closeEditing();
3934
4033
  };
3935
4034
  const chipText = field.label + (set ? ": " + chipLabel(field, value) : "");
@@ -3959,7 +4058,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3959
4058
  "aria-selected": on,
3960
4059
  className: ["fd-opt", on ? "is-selected" : ""].filter(Boolean).join(" "),
3961
4060
  onClick: () => {
3962
- onFilterChange(field.key, o.value);
4061
+ commit(field, o.value);
3963
4062
  setPanel(null);
3964
4063
  },
3965
4064
  children: /* @__PURE__ */ (0, import_jsx_runtime45.jsx)("span", { className: "fd-opt-label", children: o.label })
@@ -3980,8 +4079,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3980
4079
  field,
3981
4080
  value,
3982
4081
  anchorRef: editAnchorRef,
3983
- onChange: (v) => onFilterChange(field.key, v),
3984
- onRemove: () => onFilterRemove(field.key),
4082
+ onCommit: (v) => commit(field, v),
3985
4083
  onClose: closeEditing
3986
4084
  }
3987
4085
  ) : null
@@ -3996,8 +4094,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3996
4094
  field,
3997
4095
  value,
3998
4096
  anchorRef: editAnchorRef,
3999
- onChange: (v) => onFilterChange(field.key, v),
4000
- onRemove: () => onFilterRemove(field.key),
4097
+ onCommit: (v) => commit(field, v),
4001
4098
  onClose: closeEditing
4002
4099
  }
4003
4100
  ) : null
@@ -4012,8 +4109,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
4012
4109
  field,
4013
4110
  value,
4014
4111
  anchorRef: editAnchorRef,
4015
- onChange: (v) => onFilterChange(field.key, v),
4016
- onRemove: () => onFilterRemove(field.key),
4112
+ onCommit: (v) => commit(field, v),
4017
4113
  onClose: closeEditing
4018
4114
  }
4019
4115
  ) : null