@flytedan/flytebot-design-system 0.29.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.js CHANGED
@@ -1517,7 +1517,7 @@ var BANNER_TONE_STYLE = {
1517
1517
  };
1518
1518
  function Banner({ tone = "info", title, detail, actions, role }) {
1519
1519
  const t = BANNER_TONE_STYLE[tone];
1520
- const liveRole = role || (tone === "danger" || tone === "warn" ? "alert" : "status");
1520
+ const liveRole = role || (tone === "danger" ? "alert" : "status");
1521
1521
  return /* @__PURE__ */ jsxs19(
1522
1522
  "div",
1523
1523
  {
@@ -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,17 +2788,34 @@ function NumberInput({
2786
2788
  const n = Number(s);
2787
2789
  return isNaN(n) ? null : n;
2788
2790
  };
2791
+ const validationMessage = (n) => {
2792
+ if (integer && !Number.isInteger(n)) return "Enter a whole number";
2793
+ if (n < min) return "Must be \u2265 " + min;
2794
+ if (n > max) return "Must be \u2264 " + max;
2795
+ return null;
2796
+ };
2789
2797
  const [text, setText] = React21.useState(value == null || value === "" ? "" : String(value));
2790
2798
  const [editing, setEditing] = React21.useState(false);
2799
+ const [invalidMessage, setInvalidMessage] = React21.useState(null);
2791
2800
  const timer = React21.useRef(null);
2792
2801
  React21.useEffect(() => {
2793
- if (!editing) setText(value == null || value === "" ? "" : String(value));
2802
+ if (!editing) {
2803
+ setText(value == null || value === "" ? "" : String(value));
2804
+ setInvalidMessage(null);
2805
+ }
2794
2806
  }, [value, editing]);
2807
+ React21.useEffect(() => {
2808
+ onValidityChange && onValidityChange(invalidMessage == null);
2809
+ }, [invalidMessage]);
2795
2810
  const clamp = (n) => Math.min(max, Math.max(min, n));
2796
2811
  const fire = (n) => onChange && onChange({ target: { value: n } });
2797
2812
  const bump = (dir, mult = 1) => {
2798
- const cur = num(text) ?? num(value) ?? (min === -Infinity ? 0 : min);
2799
- 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);
2818
+ setInvalidMessage(null);
2800
2819
  setText(String(next));
2801
2820
  fire(next);
2802
2821
  };
@@ -2820,7 +2839,8 @@ function NumberInput({
2820
2839
  const n = num(text);
2821
2840
  return n == null ? "" : format ? n.toLocaleString() : String(n);
2822
2841
  })();
2823
- const box = ["fd-input", "fd-input-num", error ? "fd-input-error" : "", disabled ? "fd-input-disabled" : ""].filter(Boolean).join(" ");
2842
+ const shownError = error || invalidMessage || void 0;
2843
+ const box = ["fd-input", "fd-input-num", shownError ? "fd-input-error" : "", disabled ? "fd-input-disabled" : ""].filter(Boolean).join(" ");
2824
2844
  const fieldName = label || ariaLabel;
2825
2845
  const stepperName = (verb) => fieldName ? verb + " " + fieldName : verb;
2826
2846
  return /* @__PURE__ */ jsxs36("div", { className: ["fd-field", className].filter(Boolean).join(" "), style, children: [
@@ -2836,30 +2856,29 @@ function NumberInput({
2836
2856
  disabled,
2837
2857
  placeholder,
2838
2858
  value: shown,
2839
- "aria-invalid": error ? "true" : void 0,
2840
- "aria-describedby": error ? errorId : void 0,
2859
+ "aria-invalid": shownError ? "true" : void 0,
2860
+ "aria-describedby": shownError ? errorId : void 0,
2841
2861
  onFocus: () => setEditing(true),
2842
2862
  onBlur: () => {
2843
2863
  setEditing(false);
2844
- if (text.trim() === "") {
2845
- fire(void 0);
2846
- return;
2847
- }
2848
- const n = num(text);
2849
- if (n != null) {
2850
- const c = clamp(n);
2851
- setText(String(c));
2852
- fire(c);
2853
- }
2864
+ if (text.trim() === "") fire(void 0);
2854
2865
  },
2855
2866
  onChange: (e) => {
2856
- setText(e.target.value);
2857
- if (e.target.value.trim() === "") {
2867
+ const raw = e.target.value;
2868
+ setText(raw);
2869
+ if (raw.trim() === "") {
2870
+ setInvalidMessage(null);
2858
2871
  fire(void 0);
2859
2872
  return;
2860
2873
  }
2861
- const n = num(e.target.value);
2862
- if (n != null) fire(n);
2874
+ const n = num(raw);
2875
+ if (n == null) {
2876
+ setInvalidMessage("Enter a number");
2877
+ return;
2878
+ }
2879
+ const message = validationMessage(n);
2880
+ setInvalidMessage(message);
2881
+ if (!message) fire(n);
2863
2882
  },
2864
2883
  onKeyDown: (e) => {
2865
2884
  if (e.key === "ArrowUp") {
@@ -2902,9 +2921,9 @@ function NumberInput({
2902
2921
  )
2903
2922
  ] })
2904
2923
  ] }),
2905
- error ? /* @__PURE__ */ jsxs36("span", { id: errorId, className: "fd-field-error", children: [
2924
+ shownError ? /* @__PURE__ */ jsxs36("span", { id: errorId, className: "fd-field-error", children: [
2906
2925
  /* @__PURE__ */ jsx42("i", { className: "ph ph-warning-circle", "aria-hidden": "true" }),
2907
- error
2926
+ shownError
2908
2927
  ] }) : help ? /* @__PURE__ */ jsx42("span", { className: "fd-field-help", children: help }) : null
2909
2928
  ] });
2910
2929
  }
@@ -3182,6 +3201,91 @@ function isFieldSet(field, value) {
3182
3201
  }
3183
3202
  }
3184
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
+ }
3185
3289
  function chipLabel(field, value) {
3186
3290
  switch (field.type) {
3187
3291
  case "text":
@@ -3234,18 +3338,30 @@ function MultiSelectPanel({
3234
3338
  field,
3235
3339
  value,
3236
3340
  anchorRef,
3237
- onChange,
3238
- onRemove,
3341
+ onCommit,
3239
3342
  onClose
3240
3343
  }) {
3241
3344
  const allowedOps = operatorsFor(field);
3242
- const operator = allowedOps.length === 1 ? allowedOps[0] : value?.operator ?? allowedOps[0];
3345
+ const [draftOperator, setDraftOperator] = React24.useState(value?.operator ?? allowedOps[0]);
3346
+ const operator = allowedOps.length === 1 ? allowedOps[0] : value?.operator ?? draftOperator;
3243
3347
  const selected = value?.values ?? [];
3244
3348
  const selectedRef = React24.useRef(selected);
3245
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
+ }, []);
3246
3359
  const commit = (nextValues, nextOperator = operator) => {
3247
- if (nextValues.length === 0) onRemove();
3248
- else onChange({ operator: nextOperator, values: nextValues });
3360
+ onCommit({ operator: nextOperator, values: nextValues });
3361
+ };
3362
+ const setOperator = (op) => {
3363
+ setDraftOperator(op);
3364
+ if (selected.length > 0) commit(selected, op);
3249
3365
  };
3250
3366
  const [mode2, setMode] = React24.useState("search");
3251
3367
  const [query, setQuery] = React24.useState("");
@@ -3283,18 +3399,20 @@ function MultiSelectPanel({
3283
3399
  const options = field.loadOptions ? asyncState.options ?? [] : (field.options ?? []).filter((o) => o.label.toLowerCase().includes(query.toLowerCase()));
3284
3400
  const showLoading = !!field.loadOptions && asyncState.loading;
3285
3401
  const showError = !!field.loadOptions && asyncState.error;
3286
- const showListbox = !showLoading && !showError;
3402
+ const hasListbox = !showLoading && !showError && options.length > 0;
3287
3403
  const isSelected = (o) => selected.some((s) => s.value === o.value);
3288
3404
  const toggle = (o) => {
3289
3405
  const next = isSelected(o) ? selected.filter((s) => s.value !== o.value) : [...selected, o];
3290
3406
  commit(next);
3291
3407
  };
3292
3408
  const nav = useListboxNav({ count: options.length, onSelect: (i) => toggle(options[i]) });
3293
- useListboxScrollIntoView(nav.active, listRef, showListbox);
3409
+ useListboxScrollIntoView(nav.active, listRef, hasListbox);
3294
3410
  React24.useEffect(() => {
3295
3411
  nav.setActive(0);
3296
3412
  }, [query, options.length]);
3297
- const activeId = showListbox && options[nav.active] ? listId + "-opt-" + nav.active : void 0;
3413
+ const activeId = hasListbox && options[nav.active] ? listId + "-opt-" + nav.active : void 0;
3414
+ const liveRole = showError ? "alert" : "status";
3415
+ const liveMessage = showLoading ? "Loading\u2026" : showError ? "Couldn't load options." : options.length === 0 ? "No matches" : "";
3298
3416
  const [pasteText, setPasteText] = React24.useState("");
3299
3417
  const [pasteLoading, setPasteLoading] = React24.useState(false);
3300
3418
  const [unmatchedTokens, setUnmatchedTokens] = React24.useState([]);
@@ -3310,19 +3428,21 @@ function MultiSelectPanel({
3310
3428
  setPasteLoading(true);
3311
3429
  setPasteError(null);
3312
3430
  field.resolve(tokens).then((result) => {
3431
+ if (!mountedRef.current) return;
3313
3432
  setPasteLoading(false);
3314
3433
  setUnmatchedTokens(result.unmatched);
3315
3434
  const merged = new Map(selectedRef.current.map((s) => [s.value, s]));
3316
3435
  for (const m of result.matched) merged.set(m.value, { value: m.value, label: m.label });
3317
- commit(Array.from(merged.values()));
3436
+ commit(Array.from(merged.values()), operatorRef.current);
3318
3437
  setPasteText("");
3319
3438
  }).catch((err) => {
3439
+ if (!mountedRef.current) return;
3320
3440
  setPasteLoading(false);
3321
3441
  setPasteError(err instanceof Error ? err.message : "Could not resolve the pasted list.");
3322
3442
  });
3323
3443
  };
3324
3444
  const header = /* @__PURE__ */ jsxs39("span", { className: "fd-stack fd-gap-sm", children: [
3325
- allowedOps.length > 1 ? /* @__PURE__ */ jsx45(OperatorToggle, { operator, onChange: (op) => commit(selected, op), label: field.label }) : null,
3445
+ allowedOps.length > 1 ? /* @__PURE__ */ jsx45(OperatorToggle, { operator, onChange: setOperator, label: field.label }) : null,
3326
3446
  field.resolve ? /* @__PURE__ */ jsx45(
3327
3447
  SegmentedControl,
3328
3448
  {
@@ -3339,8 +3459,8 @@ function MultiSelectPanel({
3339
3459
  {
3340
3460
  ref: inputRef,
3341
3461
  role: "combobox",
3342
- "aria-expanded": showListbox,
3343
- "aria-controls": showListbox ? listId : void 0,
3462
+ "aria-expanded": hasListbox,
3463
+ "aria-controls": hasListbox ? listId : void 0,
3344
3464
  "aria-autocomplete": "list",
3345
3465
  "aria-activedescendant": activeId,
3346
3466
  "aria-label": field.label,
@@ -3396,33 +3516,41 @@ function MultiSelectPanel({
3396
3516
  ] }),
3397
3517
  /* @__PURE__ */ jsx45("span", { className: "fd-row fd-wrap fd-gap-xs", children: selected.map((v) => /* @__PURE__ */ jsx45(Tag, { onRemove: () => commit(selected.filter((s) => s.value !== v.value)), children: v.label }, v.value)) })
3398
3518
  ] }) : null
3399
- ] }) : showLoading ? /* @__PURE__ */ jsxs39("span", { className: "fd-row fd-gap-xs", role: "status", children: [
3400
- /* @__PURE__ */ jsx45("span", { className: "fd-spinner", "aria-hidden": "true" }),
3401
- " Loading\u2026"
3402
- ] }) : showError ? /* @__PURE__ */ jsxs39("span", { className: "fd-stack fd-gap-sm", children: [
3403
- /* @__PURE__ */ jsx45("span", { className: "fd-body-sm", role: "alert", children: "Couldn't load options." }),
3404
- /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: () => setRetryTick((n) => n + 1), children: "Retry" })
3405
- ] }) : options.length === 0 ? /* @__PURE__ */ jsx45("span", { className: "fd-body-sm fd-muted", role: "status", children: "No matches" }) : /* @__PURE__ */ jsx45("span", { id: listId, role: "listbox", "aria-label": field.label, "aria-multiselectable": "true", ref: listRef, className: "fd-pop-list", children: options.map((o, i) => {
3406
- const on = isSelected(o);
3407
- return /* @__PURE__ */ jsxs39(
3408
- "div",
3519
+ ] }) : /* @__PURE__ */ jsxs39("span", { className: "fd-stack fd-gap-sm", children: [
3520
+ /* @__PURE__ */ jsxs39(
3521
+ "span",
3409
3522
  {
3410
- id: listId + "-opt-" + i,
3411
- "data-i": i,
3412
- role: "option",
3413
- tabIndex: -1,
3414
- "aria-selected": on,
3415
- className: ["fd-opt", i === nav.active ? "is-active" : "", on ? "is-selected" : ""].filter(Boolean).join(" "),
3416
- onMouseEnter: () => nav.setActive(i),
3417
- onClick: () => toggle(o),
3523
+ className: "fd-row fd-gap-xs" + (liveMessage && !showError ? " fd-muted" : ""),
3524
+ role: liveRole,
3418
3525
  children: [
3419
- /* @__PURE__ */ jsx45("i", { className: "fd-filterbar-opt-check ph " + (on ? "ph-check-square is-checked" : "ph-square"), "aria-hidden": "true" }),
3420
- /* @__PURE__ */ jsx45("span", { className: "fd-opt-label", children: o.label })
3526
+ showLoading ? /* @__PURE__ */ jsx45("span", { className: "fd-spinner", "aria-hidden": "true" }) : null,
3527
+ liveMessage ? /* @__PURE__ */ jsx45("span", { className: "fd-body-sm", children: liveMessage }) : null
3421
3528
  ]
3422
- },
3423
- o.value
3424
- );
3425
- }) });
3529
+ }
3530
+ ),
3531
+ showError ? /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: () => setRetryTick((n) => n + 1), children: "Retry" }) : null,
3532
+ hasListbox ? /* @__PURE__ */ jsx45("span", { id: listId, role: "listbox", "aria-label": field.label, "aria-multiselectable": "true", ref: listRef, className: "fd-pop-list", children: options.map((o, i) => {
3533
+ const on = isSelected(o);
3534
+ return /* @__PURE__ */ jsxs39(
3535
+ "div",
3536
+ {
3537
+ id: listId + "-opt-" + i,
3538
+ "data-i": i,
3539
+ role: "option",
3540
+ tabIndex: -1,
3541
+ "aria-selected": on,
3542
+ className: ["fd-opt", i === nav.active ? "is-active" : "", on ? "is-selected" : ""].filter(Boolean).join(" "),
3543
+ onMouseEnter: () => nav.setActive(i),
3544
+ onClick: () => toggle(o),
3545
+ children: [
3546
+ /* @__PURE__ */ jsx45("i", { className: "fd-filterbar-opt-check ph " + (on ? "ph-check-square is-checked" : "ph-square"), "aria-hidden": "true" }),
3547
+ /* @__PURE__ */ jsx45("span", { className: "fd-opt-label", children: o.label })
3548
+ ]
3549
+ },
3550
+ o.value
3551
+ );
3552
+ }) }) : null
3553
+ ] });
3426
3554
  const footer = mode2 === "paste" ? /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: doResolve, disabled: pasteLoading || !pasteText.trim(), children: pasteLoading ? "Resolving\u2026" : "Resolve" }) : void 0;
3427
3555
  return /* @__PURE__ */ jsx45(
3428
3556
  Popover,
@@ -3444,22 +3572,21 @@ function RangePanel({
3444
3572
  field,
3445
3573
  value,
3446
3574
  anchorRef,
3447
- onChange,
3448
- onRemove,
3575
+ onCommit,
3449
3576
  onClose
3450
3577
  }) {
3451
3578
  const [draftMin, setDraftMin] = React24.useState(value?.min);
3452
3579
  const [draftMax, setDraftMax] = React24.useState(value?.max);
3453
- 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;
3454
3586
  const apply = (nextMin, nextMax) => {
3455
3587
  setDraftMin(nextMin);
3456
3588
  setDraftMax(nextMax);
3457
- if (nextMin == null && nextMax == null) {
3458
- onRemove();
3459
- return;
3460
- }
3461
- if (nextMin != null && nextMax != null && nextMin > nextMax) return;
3462
- onChange({ min: nextMin, max: nextMax });
3589
+ onCommit(nextMin == null && nextMax == null ? void 0 : { min: nextMin, max: nextMax });
3463
3590
  };
3464
3591
  return /* @__PURE__ */ jsx45(
3465
3592
  Popover,
@@ -3471,7 +3598,7 @@ function RangePanel({
3471
3598
  offset: 6,
3472
3599
  width: PANEL_WIDTH.range,
3473
3600
  label: field.label,
3474
- footer: /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: onClose, children: "Done" }),
3601
+ footer: /* @__PURE__ */ jsx45(Button, { size: "sm", variant: "secondary", onClick: onClose, disabled: doneDisabled, children: "Done" }),
3475
3602
  children: /* @__PURE__ */ jsxs39("span", { className: "fd-stack fd-gap-sm", children: [
3476
3603
  /* @__PURE__ */ jsx45(
3477
3604
  NumberInput,
@@ -3481,8 +3608,10 @@ function RangePanel({
3481
3608
  min: field.min,
3482
3609
  max: field.max,
3483
3610
  step: field.step,
3611
+ integer: field.integer,
3484
3612
  placeholder: field.minPlaceholder,
3485
- onChange: (e) => apply(e.target.value, draftMax)
3613
+ onChange: (e) => apply(e.target.value, draftMax),
3614
+ onValidityChange: setMinTextValid
3486
3615
  }
3487
3616
  ),
3488
3617
  /* @__PURE__ */ jsx45(
@@ -3493,9 +3622,11 @@ function RangePanel({
3493
3622
  min: field.min,
3494
3623
  max: field.max,
3495
3624
  step: field.step,
3496
- error: invalid ? "Must be \u2265 " + draftMin : void 0,
3625
+ integer: field.integer,
3626
+ error: comboError,
3497
3627
  placeholder: field.maxPlaceholder,
3498
- onChange: (e) => apply(draftMin, e.target.value)
3628
+ onChange: (e) => apply(draftMin, e.target.value),
3629
+ onValidityChange: setMaxTextValid
3499
3630
  }
3500
3631
  )
3501
3632
  ] })
@@ -3506,49 +3637,35 @@ function DatePanel({
3506
3637
  field,
3507
3638
  value,
3508
3639
  anchorRef,
3509
- onChange,
3510
- onRemove,
3640
+ onCommit,
3511
3641
  onClose
3512
3642
  }) {
3513
3643
  const defaultDays = field.defaultDays ?? 30;
3514
- const [draft, setDraft] = React24.useState(value ?? { kind: "withinDays", days: defaultDays });
3515
- const committedRef = React24.useRef(!!value);
3644
+ const maxDays = field.maxDays ?? 3650;
3645
+ const [kind, setKind] = React24.useState(value?.kind ?? "withinDays");
3646
+ const [daysDraft, setDaysDraft] = React24.useState(
3647
+ value?.kind === "withinDays" ? value.days : defaultDays
3648
+ );
3649
+ const [between, setBetween] = React24.useState(
3650
+ value?.kind === "between" ? { from: value.from, to: value.to } : { from: "", to: "" }
3651
+ );
3516
3652
  const applyWithinDays = (days) => {
3517
- const next = { kind: "withinDays", days: days ?? defaultDays };
3518
- setDraft(next);
3519
- if (days != null) {
3520
- committedRef.current = true;
3521
- onChange(next);
3522
- }
3653
+ setDaysDraft(days);
3654
+ onCommit(days == null ? void 0 : { kind: "withinDays", days });
3523
3655
  };
3524
3656
  const applyBetween = (from, to) => {
3525
- const next = { kind: "between", from, to };
3526
- setDraft(next);
3527
- if (from && to) {
3528
- committedRef.current = true;
3529
- onChange(next);
3530
- } else if (committedRef.current) {
3531
- committedRef.current = false;
3532
- onRemove();
3533
- }
3657
+ setBetween({ from, to });
3658
+ onCommit(from && to ? { kind: "between", from, to } : void 0);
3534
3659
  };
3535
- const switchKind = (kind) => {
3536
- if (kind === "withinDays") {
3537
- const days = draft.kind === "withinDays" ? draft.days : defaultDays;
3538
- setDraft({ kind: "withinDays", days });
3539
- } else {
3540
- setDraft({ kind: "between", from: "", to: "" });
3541
- if (committedRef.current) {
3542
- committedRef.current = false;
3543
- onRemove();
3544
- }
3545
- }
3660
+ const switchKind = (next) => {
3661
+ if (next === kind) return;
3662
+ setKind(next);
3663
+ if (next === "between") setBetween({ from: "", to: "" });
3664
+ else setDaysDraft(defaultDays);
3665
+ onCommit(void 0);
3546
3666
  };
3547
3667
  const confirm = () => {
3548
- if (draft.kind === "withinDays") {
3549
- committedRef.current = true;
3550
- onChange(draft);
3551
- }
3668
+ if (kind === "withinDays" && daysDraft != null) onCommit({ kind: "withinDays", days: daysDraft });
3552
3669
  onClose();
3553
3670
  };
3554
3671
  return /* @__PURE__ */ jsx45(
@@ -3571,16 +3688,16 @@ function DatePanel({
3571
3688
  { value: "withinDays", label: "Within last N days" },
3572
3689
  { value: "between", label: "Between dates" }
3573
3690
  ],
3574
- value: draft.kind,
3691
+ value: kind,
3575
3692
  onChange: (k) => switchKind(k)
3576
3693
  }
3577
3694
  ),
3578
- draft.kind === "withinDays" ? /* @__PURE__ */ jsx45(NumberInput, { label: "Days", min: 1, value: draft.days, 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(
3579
3696
  DatePicker,
3580
3697
  {
3581
3698
  range: true,
3582
3699
  label: "Date range",
3583
- value: { start: draft.from, end: draft.to },
3700
+ value: { start: between.from, end: between.to },
3584
3701
  onChange: (e) => applyBetween(e.target.value.start || "", e.target.value.end || "")
3585
3702
  }
3586
3703
  )
@@ -3594,6 +3711,21 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3594
3711
  const addAnchorRef = React24.useRef(null);
3595
3712
  const textInputRef = React24.useRef(null);
3596
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
+ };
3597
3729
  const editingField = panel && panel.kind === "edit" ? fields.find((f) => f.key === panel.key) : void 0;
3598
3730
  const startEditing = (key) => setPanel({ kind: "edit", key });
3599
3731
  const closeEditing = () => setPanel(null);
@@ -3625,7 +3757,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3625
3757
  value: value ?? "",
3626
3758
  placeholder: field.placeholder || field.label,
3627
3759
  "aria-label": field.label,
3628
- onChange: (e) => onFilterChange(field.key, e.target.value),
3760
+ onChange: (e) => commit(field, e.target.value),
3629
3761
  onKeyDown: (e) => {
3630
3762
  if (e.key === "Enter") setPanel(null);
3631
3763
  else if (e.key === "Escape") {
@@ -3644,7 +3776,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3644
3776
  "aria-label": "Remove " + field.label,
3645
3777
  onClick: (e) => {
3646
3778
  e.stopPropagation();
3647
- onFilterRemove(field.key);
3779
+ removeField(field);
3648
3780
  setPanel(null);
3649
3781
  },
3650
3782
  children: /* @__PURE__ */ jsx45("i", { className: "ph ph-x", "aria-hidden": "true" })
@@ -3657,7 +3789,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3657
3789
  const chipRef = editing ? editAnchorRef : void 0;
3658
3790
  const toggleOrClose = () => editing ? closeEditing() : startEditing(field.key);
3659
3791
  const remove = () => {
3660
- if (set) onFilterRemove(field.key);
3792
+ removeField(field);
3661
3793
  closeEditing();
3662
3794
  };
3663
3795
  const chipText = field.label + (set ? ": " + chipLabel(field, value) : "");
@@ -3687,7 +3819,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3687
3819
  "aria-selected": on,
3688
3820
  className: ["fd-opt", on ? "is-selected" : ""].filter(Boolean).join(" "),
3689
3821
  onClick: () => {
3690
- onFilterChange(field.key, o.value);
3822
+ commit(field, o.value);
3691
3823
  setPanel(null);
3692
3824
  },
3693
3825
  children: /* @__PURE__ */ jsx45("span", { className: "fd-opt-label", children: o.label })
@@ -3708,8 +3840,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3708
3840
  field,
3709
3841
  value,
3710
3842
  anchorRef: editAnchorRef,
3711
- onChange: (v) => onFilterChange(field.key, v),
3712
- onRemove: () => onFilterRemove(field.key),
3843
+ onCommit: (v) => commit(field, v),
3713
3844
  onClose: closeEditing
3714
3845
  }
3715
3846
  ) : null
@@ -3724,8 +3855,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3724
3855
  field,
3725
3856
  value,
3726
3857
  anchorRef: editAnchorRef,
3727
- onChange: (v) => onFilterChange(field.key, v),
3728
- onRemove: () => onFilterRemove(field.key),
3858
+ onCommit: (v) => commit(field, v),
3729
3859
  onClose: closeEditing
3730
3860
  }
3731
3861
  ) : null
@@ -3740,8 +3870,7 @@ function FilterBar({ fields = [], filters, onFilterChange, onFilterRemove, align
3740
3870
  field,
3741
3871
  value,
3742
3872
  anchorRef: editAnchorRef,
3743
- onChange: (v) => onFilterChange(field.key, v),
3744
- onRemove: () => onFilterRemove(field.key),
3873
+ onCommit: (v) => commit(field, v),
3745
3874
  onClose: closeEditing
3746
3875
  }
3747
3876
  ) : null