{"version":3,"file":"filter.mjs","names":[],"sources":["../src/filter/set-filter.model.ts","../src/filter/bucket-filter.model.ts","../src/filter/date-filter.model.ts","../src/filter/number-filter.model.ts","../src/filter/text-filter.model.ts"],"sourcesContent":["import { action, computed, makeObservable, observable } from \"mobx\";\nimport type {\n  FilterCondition,\n  SetFilterOptions,\n  SetFilterProps,\n  SetFilterState,\n  SetFilterValue,\n  SetMatchMode,\n  ValueFilter,\n} from \"./filter.types\";\nimport { facetValues } from \"./util\";\n\n/**\n * A filter over a discrete set of values — the checkbox-list filter.\n *\n * Nothing here knows what a row is. It is handed one already-extracted value and answers whether\n * that value passes, which is what lets the same instance sit on a table column, a sidebar rail, or\n * a plain `array.filter`.\n *\n * Blanks need no separate state: {@link BLANK} sits inside `selected` like any other value, so\n * `matches`, `has`, `toggle`, `select`, `value`, `active`, `selectedCount` and `clear` are all\n * unchanged by it, and \"select all\" needs no special case.\n */\nexport class SetFilter implements ValueFilter {\n  /** The chosen values. Empty = inactive = everything passes. */\n  selected = new Set<SetFilterValue>();\n\n  /**\n   * How selections combine — any of, all of, or none of. Observable because it is *state*, not\n   * configuration: a UI can offer the toggle. See {@link SetMatchMode}; only `\"all\"` is restricted\n   * to array-valued data.\n   */\n  matchMode: SetMatchMode = \"any\";\n\n  /**\n   * Whatever your components need to render this filter. Empty until you augment\n   * {@link SetFilterProps} — the library never reads it.\n   */\n  readonly props: SetFilterProps;\n\n  /** Declared value domain, in declaration order. See {@link SetFilterOptions.options}. */\n  readonly options: readonly SetFilterValue[] | undefined;\n\n  /**\n   * Groups raw values before they are compared. See {@link SetFilterOptions.project} — and note\n   * `matches` applies it itself, so callers pass raw values in.\n   */\n  readonly project: ((value: unknown) => unknown) | undefined;\n\n  /** Whether facet counts were asked for. See {@link SetFilterOptions.counts}. */\n  readonly counts: boolean;\n\n  /**\n   * Whether the values are arrays, and so whether a UI should offer `\"all\"` as a match mode.\n   * `\"none\"` is not gated by it. Advisory only — see {@link SetFilterOptions.multiValue}.\n   */\n  readonly multiValue: boolean;\n\n  get active(): boolean {\n    return this.selected.size > 0;\n  }\n\n  /** How many values are selected — the number a filter chip shows. */\n  get selectedCount(): number {\n    return this.selected.size;\n  }\n\n  /** JSON-serializable state; round-trips through {@link setValue}. */\n  get value(): SetFilterState {\n    return { selected: [...this.selected], matchMode: this.matchMode };\n  }\n\n  /**\n   * True in every mode but `\"any\"` — under `\"all\"` and `\"none\"` alike each additional pick narrows\n   * the result instead of widening it, so facet counts have to be taken against the current\n   * selection rather than ignoring it. See {@link ValueFilter.intersecting}.\n   *\n   * The count that produces reads differently in the two modes, and both readings are the useful\n   * one. Under `\"all\"` it is \"tick this too and you get that many rows\". Under `\"none\"` the walk\n   * counts rows this filter currently *admits* that carry the value — which is exactly the rows\n   * ticking it would remove, so it reads as \"excluding this drops that many\". An already-excluded\n   * value therefore tallies zero, which is true: excluding it again removes nothing. Zero-count\n   * entries are kept in the facet list, so it can still be unticked.\n   */\n  get intersecting(): boolean {\n    return this.matchMode !== \"any\";\n  }\n\n  /**\n   * The selection as a server condition — `\"in\"`, `\"all\"` or `\"notIn\"`, following the match mode.\n   * `undefined` while inactive.\n   */\n  get condition(): FilterCondition | undefined {\n    if (this.selected.size === 0) return undefined;\n    const op = this.matchMode === \"all\" ? \"all\" : this.matchMode === \"none\" ? \"notIn\" : \"in\";\n    return { op, value: [...this.selected] };\n  }\n\n  constructor(options?: SetFilterOptions) {\n    this.options = options?.options;\n    this.project = options?.project;\n    this.counts = options?.counts === true;\n    this.multiValue = options?.multiValue === true;\n    if (options?.matchMode) this.matchMode = options.matchMode;\n    if (options?.selected) for (const v of options.selected) this.selected.add(v);\n\n    this.props = options?.props ?? {};\n\n    makeObservable(this, {\n      selected: observable.shallow,\n      matchMode: observable,\n\n      active: computed,\n      selectedCount: computed,\n      value: computed,\n      condition: computed,\n      intersecting: computed,\n\n      toggle: action.bound,\n      select: action.bound,\n      setMatchMode: action.bound,\n      setValue: action.bound,\n      clear: action.bound,\n    });\n  }\n\n  matches(value: unknown): boolean {\n    if (this.selected.size === 0) return true;\n\n    const values = facetValues(this.project ? this.project(value) : value);\n    if (this.matchMode === \"all\") {\n      for (const s of this.selected) if (!values.has(s)) return false;\n      return true;\n    }\n    // `\"any\"` and `\"none\"` ask the same question — is any selection present — and differ only in\n    // the answer they want, so they share the walk rather than inverting one another's result.\n    for (const v of values) if (this.selected.has(v)) return this.matchMode !== \"none\";\n    return this.matchMode === \"none\";\n  }\n\n  has(value: SetFilterValue): boolean {\n    return this.selected.has(value);\n  }\n\n  toggle(value: SetFilterValue): void {\n    if (this.selected.has(value)) this.selected.delete(value);\n    else this.selected.add(value);\n  }\n\n  /** Replace the whole selection. Passing nothing clears it. */\n  select(values?: Iterable<SetFilterValue>): void {\n    this.selected.clear();\n    if (values) for (const v of values) this.selected.add(v);\n  }\n\n  setMatchMode(matchMode: SetMatchMode): void {\n    this.matchMode = matchMode;\n  }\n\n  /**\n   * Restore state from {@link value}. Anything that is not a recognisable set-filter snapshot —\n   * a range filter's state left in storage under this key, a hand-edited URL — resets rather than\n   * being trusted, and unusable entries within a valid one are dropped.\n   */\n  setValue(value?: unknown): void {\n    const state = (value ?? {}) as Partial<SetFilterState>;\n    const selected = Array.isArray(state.selected)\n      ? state.selected.filter(\n          (v): v is SetFilterValue =>\n            typeof v === \"string\" || typeof v === \"number\" || typeof v === \"boolean\",\n        )\n      : undefined;\n    this.select(selected);\n    this.matchMode =\n      state.matchMode === \"all\" || state.matchMode === \"none\" ? state.matchMode : \"any\";\n  }\n\n  /**\n   * Clear the selection. `matchMode` is left alone — it is a mode the user chose, like a sort\n   * direction, not part of what is being filtered.\n   */\n  clear(): void {\n    this.selected.clear();\n  }\n}\n","import { computed, makeObservable } from \"mobx\";\nimport type {\n  Bucket,\n  BucketFilterOptions,\n  BucketFilterProps,\n  SetFilterValue,\n} from \"./filter.types\";\nimport { SetFilter } from \"./set-filter.model\";\nimport { isBlank } from \"./util\";\n\n/**\n * Build the projection a set of buckets describes: a value in, its bucket's label out.\n *\n * Ranges are `[min, max)` — inclusive lower, exclusive upper — so two adjacent buckets sharing a\n * number don't both claim it. The first matching bucket wins, which is what makes overlapping\n * definitions resolve by declaration order instead of being an error nobody can act on.\n *\n * Exported because the projection is useful without the filter: the same function labels a value\n * for a cell renderer or a chart legend, and reusing it is what keeps the table and the filter\n * agreeing on which bucket a score is in.\n */\nexport const bucketProjection =\n  (buckets: readonly Bucket[]) =>\n  (value: unknown): unknown => {\n    // blanks stay blank — a missing score is not a low one, and `facetValues` gives it its own facet\n    if (isBlank(value)) return value;\n    const n = typeof value === \"number\" ? value : Number(value);\n    if (!Number.isFinite(n)) return value;\n    for (const bucket of buckets) {\n      if (bucket.min !== undefined && n < bucket.min) continue;\n      if (bucket.max !== undefined && n >= bucket.max) continue;\n      return bucket.label;\n    }\n    // outside every bucket: hand back the raw value rather than inventing a label, so it shows up\n    // in the facet list as itself instead of vanishing\n    return value;\n  };\n\n/**\n * A set filter over named ranges — pick \"B\" rather than typing 80 to 90.\n *\n * A `SetFilter` whose domain is derived, and deliberately a subclass rather than a parallel type: a\n * bucket filter *is* a checkbox list, so everything already built for one applies — facets, counts,\n * blanks, match modes, serialization — and a popover narrowing by `instanceof SetFilter` renders it\n * with no changes.\n *\n * The column keeps showing and sorting the **raw** value; only the filter sees the buckets. That is\n * the point: a score column still sorts 84 above 81 inside the \"B\" bucket.\n *\n * ```ts\n * {\n *   key: \"score\",\n *   filter: () => new BucketFilter({\n *     buckets: [\n *       { label: \"A\", min: 90 },\n *       { label: \"B\", min: 80, max: 90 },\n *       { label: \"C\", min: 70, max: 80 },\n *       { label: \"D\", min: 60, max: 70 },\n *       { label: \"F\", max: 60 },\n *     ],\n *   }),\n * }\n * ```\n *\n * Note for server mode: the condition carries the selected *labels*, which a server can only act on\n * if it knows the same bucket definitions. Map them to ranges yourself when building the request, or\n * keep bucket filters client-side.\n */\nexport class BucketFilter extends SetFilter {\n  readonly buckets: readonly Bucket[];\n\n  /** Narrowed to {@link BucketFilterProps}; see {@link SetFilterProps}. */\n  declare readonly props: BucketFilterProps;\n\n  /** The bucket a value falls in, or `undefined` when it falls outside every one. */\n  bucketOf(value: unknown): Bucket | undefined {\n    const label = this.project?.(value);\n    return this.buckets.find((b) => b.label === label);\n  }\n\n  constructor(options: BucketFilterOptions) {\n    const buckets = [...options.buckets];\n    super({\n      // the labels *are* the domain, derived rather than declared twice so they cannot drift\n      options: buckets.map((b) => b.label as SetFilterValue),\n      project: bucketProjection(buckets),\n      counts: options.counts,\n      matchMode: options.matchMode,\n      selected: options.selected,\n      props: options.props,\n    });\n    this.buckets = buckets;\n\n    makeObservable(this, { bucketOf: false, buckets: false, labels: computed });\n  }\n\n  /** The bucket labels, in declaration order — the same list `options` holds. */\n  get labels(): SetFilterValue[] {\n    return this.buckets.map((b) => b.label);\n  }\n}\n","import { action, computed, makeObservable, observable } from \"mobx\";\nimport type {\n  DateFilterOptions,\n  DateFilterProps,\n  DateFilterState,\n  DateLike,\n  DateUnit,\n  FilterCondition,\n  ValueFilter,\n} from \"./filter.types\";\n\n// Below this, a bare number reads as seconds; at or above, as milliseconds. The boundary is 1973 in\n// milliseconds and the year 5138 in seconds, so nothing in between is a date anyone means.\nconst SECONDS_CEILING = 1e11;\n\n/**\n * Coerce anything date-shaped to epoch milliseconds, or `undefined` when it isn't one.\n *\n * Three inputs, because a \"date column\" is any of them depending on where the data came from: a\n * hydrated `Date`, a JSON timestamp, or an ISO string straight off the wire. Making the filter\n * absorb the difference is what stops every consumer writing the same three-branch coercion.\n */\nconst toTime = (value: unknown, unit: DateUnit): number | undefined => {\n  const fromNumber = (n: number): number | undefined => {\n    if (!Number.isFinite(n)) return undefined;\n    if (unit === \"ms\") return n;\n    if (unit === \"s\") return n * 1000;\n    return Math.abs(n) < SECONDS_CEILING ? n * 1000 : n;\n  };\n\n  if (value instanceof Date) {\n    const t = value.getTime();\n    return Number.isNaN(t) ? undefined : t;\n  }\n  if (typeof value === \"number\") return fromNumber(value);\n  if (typeof value === \"string\") {\n    const trimmed = value.trim();\n    if (trimmed === \"\") return undefined;\n    // an all-digits string is a timestamp that went through JSON as text, not a date string\n    if (/^[+-]?\\d+$/.test(trimmed)) return fromNumber(Number(trimmed));\n    const parsed = Date.parse(trimmed);\n    return Number.isNaN(parsed) ? undefined : parsed;\n  }\n  return undefined;\n};\n\n/**\n * An inclusive date range, either bound optional.\n *\n * Absorbs the three shapes a date column actually arrives in — `Date`, epoch number, ISO string —\n * on both sides: the cell values it compares and the bounds you hand it. So\n * `setRange(\"2020-01-01\", new Date())` works over a column of unix timestamps.\n *\n * Bounds are stored as epoch **milliseconds**, which is what keeps `value` a pair of plain numbers\n * and the JSON round-trip free of date-string parsing.\n */\nexport class DateFilter implements ValueFilter {\n  min: number | undefined;\n  max: number | undefined;\n\n  /**\n   * Whatever your components need to render this filter. Empty until you augment\n   * {@link DateFilterProps} — the library never reads it.\n   */\n  readonly props: DateFilterProps;\n\n  /** How a bare number is read. See {@link DateFilterOptions.unit}. */\n  readonly unit: DateUnit;\n\n  get active(): boolean {\n    return this.min !== undefined || this.max !== undefined;\n  }\n\n  /** JSON-serializable state; round-trips through {@link setValue}. Unset bounds are omitted. */\n  get value(): DateFilterState {\n    const state: DateFilterState = {};\n    if (this.min !== undefined) state.min = this.min;\n    if (this.max !== undefined) state.max = this.max;\n    return state;\n  }\n\n  /** The bounds as a server condition, in epoch milliseconds. `undefined` while inactive. */\n  get condition(): FilterCondition | undefined {\n    if (!this.active) return undefined;\n    return { op: \"range\", value: this.value };\n  }\n\n  /** The bounds as `Date` objects, for handing to a date picker. */\n  get range(): { min?: Date; max?: Date } {\n    const range: { min?: Date; max?: Date } = {};\n    if (this.min !== undefined) range.min = new Date(this.min);\n    if (this.max !== undefined) range.max = new Date(this.max);\n    return range;\n  }\n\n  constructor(options?: DateFilterOptions) {\n    this.unit = options?.unit ?? \"auto\";\n    this.min = toTime(options?.min, this.unit);\n    this.max = toTime(options?.max, this.unit);\n\n    this.props = options?.props ?? {};\n\n    makeObservable(this, {\n      min: observable,\n      max: observable,\n\n      active: computed,\n      value: computed,\n      condition: computed,\n      range: computed,\n\n      setMin: action.bound,\n      setMax: action.bound,\n      setRange: action.bound,\n      setValue: action.bound,\n      clear: action.bound,\n    });\n  }\n\n  /**\n   * Inclusive on both ends. A value that isn't date-shaped fails while the filter is active — a\n   * blank cell is outside every range, which is what a date picker implies.\n   */\n  matches(value: unknown): boolean {\n    if (this.min === undefined && this.max === undefined) return true;\n\n    const t = toTime(value, this.unit);\n    if (t === undefined) return false;\n    if (this.min !== undefined && t < this.min) return false;\n    if (this.max !== undefined && t > this.max) return false;\n    return true;\n  }\n\n  setMin(min: DateLike | undefined): void {\n    this.min = toTime(min, this.unit);\n  }\n\n  setMax(max: DateLike | undefined): void {\n    this.max = toTime(max, this.unit);\n  }\n\n  setRange(min: DateLike | undefined, max: DateLike | undefined): void {\n    this.min = toTime(min, this.unit);\n    this.max = toTime(max, this.unit);\n  }\n\n  /**\n   * Restore state from {@link value}. Bounds are expected as epoch milliseconds, since that is what\n   * `value` emits; anything non-numeric is dropped rather than trusted.\n   */\n  setValue(value?: unknown): void {\n    const state = (value ?? {}) as Partial<DateFilterState>;\n    const bound = (n: unknown): number | undefined =>\n      typeof n === \"number\" && Number.isFinite(n) ? n : undefined;\n    this.min = bound(state.min);\n    this.max = bound(state.max);\n  }\n\n  clear(): void {\n    this.min = undefined;\n    this.max = undefined;\n  }\n}\n","import { action, computed, makeObservable, observable } from \"mobx\";\nimport type {\n  FilterCondition,\n  IntervalNumberOp,\n  NumberBounds,\n  NumberFilterOptions,\n  NumberFilterProps,\n  NumberFilterState,\n  NumberOp,\n  UnaryNumberOp,\n  ValueFilter,\n} from \"./filter.types\";\n\nconst INTERVAL_OPS = new Set<string>([\"between\", \"betweenExclusive\"]);\n\n/** Whether an op takes a pair of bounds rather than a single number. */\nexport const isIntervalOp = (op: NumberOp): op is IntervalNumberOp => INTERVAL_OPS.has(op);\n\nconst toNumber = (value: unknown): number | undefined => {\n  if (typeof value === \"number\") return Number.isFinite(value) ? value : undefined;\n  if (typeof value === \"string\" && value.trim() !== \"\") {\n    const n = Number(value);\n    return Number.isFinite(n) ? n : undefined;\n  }\n  return undefined;\n};\n\nconst bound = (v: unknown): number | undefined =>\n  typeof v === \"number\" && Number.isFinite(v) ? v : undefined;\n\n// Absent keys rather than a fixed-length shape, so this accepts a partially filled control and a\n// snapshot written by an older version alike. Both ends open is well-formed but inactive.\nconst toBounds = (v: unknown): NumberBounds | undefined => {\n  if (typeof v !== \"object\" || v === null || Array.isArray(v)) return undefined;\n  const { min, max } = v as NumberBounds;\n  const bounds: NumberBounds = {};\n  if (bound(min) !== undefined) bounds.min = bound(min);\n  if (bound(max) !== undefined) bounds.max = bound(max);\n  return bounds;\n};\n\n/**\n * A numeric comparison: an operator plus its operand.\n *\n * The operand's shape follows the operator — a single number for `eq`/`neq`/`gt`/`lt`/`gte`/`lte`,\n * `{ min, max }` for the two `between` variants — and the types tie the two together, so a\n * mismatched pair is a compile error at the call site rather than something `active` has to reject.\n *\n * ```ts\n * new NumberFilter({ op: \"gte\", operand: 60 });\n * new NumberFilter({ op: \"between\", operand: { min: 60, max: 80 } });\n * new NumberFilter({ op: \"between\", operand: { min: 60 } }); // 60 and up\n * ```\n *\n * Interval bounds are **independently optional**, and `min` / `max` / `setMin` / `setMax` let a\n * two-input range control read and write them one at a time — the same shape `DateFilter` uses. That\n * is what keeps such a control stateless: clearing the upper box leaves the lower one alone, so there\n * is no draft copy in component state and nothing to go stale when something else calls\n * `clearColumnFilters()`.\n *\n * ```tsx\n * <input value={filter.min ?? \"\"} onChange={(e) => filter.setMin(parse(e.target.value))} />\n * <input value={filter.max ?? \"\"} onChange={(e) => filter.setMax(parse(e.target.value))} />\n * ```\n *\n * For dates use `DateFilter`, which speaks `Date`s and ISO strings; for grouping numbers into named\n * ranges use `BucketFilter`.\n */\nexport class NumberFilter implements ValueFilter {\n  op: NumberOp = \"eq\";\n\n  /**\n   * Whatever your components need to render this filter. Empty until you augment\n   * {@link NumberFilterProps} — the library never reads it.\n   */\n  readonly props: NumberFilterProps;\n\n  /**\n   * A single number, or `{ min, max }` for the interval ops — each bound independently optional, so\n   * a range control can hold one while the other is still empty.\n   */\n  operand: number | NumberBounds | undefined;\n\n  /**\n   * Whether the operand actually fits the operator. Switching operator without switching operand\n   * leaves the filter inactive rather than guessing — a `[60, 80]` pair means nothing to `gte`, and\n   * silently taking the first element would filter by something the user never asked for. Use\n   * {@link set} to change both at once.\n   */\n  get active(): boolean {\n    const operand = this.operand;\n    if (operand === undefined) return false;\n    if (!isIntervalOp(this.op)) return typeof operand === \"number\";\n    const bounds = toBounds(operand);\n    return bounds !== undefined && (bounds.min !== undefined || bounds.max !== undefined);\n  }\n\n  /**\n   * The lower bound, for an interval operator. `undefined` for the unary ones, where a UI renders\n   * one input rather than two.\n   *\n   * Named to match `DateFilter`, and present so a range control can drive its inputs straight off\n   * the filter — read `min`, write `setMin` — with no draft copy in component state, and therefore\n   * nothing to go stale when something else calls `clearColumnFilters()`.\n   */\n  get min(): number | undefined {\n    return isIntervalOp(this.op) ? toBounds(this.operand)?.min : undefined;\n  }\n\n  /** The upper bound, for an interval operator. `undefined` for the unary ones. */\n  get max(): number | undefined {\n    return isIntervalOp(this.op) ? toBounds(this.operand)?.max : undefined;\n  }\n\n  /** JSON-serializable state; round-trips through {@link setValue}. */\n  get value(): NumberFilterState {\n    if (!isIntervalOp(this.op)) {\n      return { op: this.op, operand: bound(this.operand) };\n    }\n    return { op: this.op, operand: this.active ? { ...toBounds(this.operand) } : undefined };\n  }\n\n  get condition(): FilterCondition | undefined {\n    if (!this.active) return undefined;\n    return { op: this.op, value: this.value.operand };\n  }\n\n  constructor(options?: NumberFilterOptions) {\n    if (options?.op) this.op = options.op;\n    if (options?.operand !== undefined) {\n      this.operand = toBounds(options.operand) ?? options.operand;\n    }\n\n    this.props = options?.props ?? {};\n\n    makeObservable(this, {\n      op: observable,\n      operand: observable.ref,\n\n      active: computed,\n      value: computed,\n      condition: computed,\n\n      min: computed,\n      max: computed,\n\n      setOp: action.bound,\n      setOperand: action.bound,\n      setMin: action.bound,\n      setMax: action.bound,\n      setRange: action.bound,\n      set: action.bound,\n      setValue: action.bound,\n      clear: action.bound,\n    });\n  }\n\n  /**\n   * Numeric strings are accepted, because a column of `\"42\"` is a data shape rather than a mistake.\n   * Anything that isn't a number fails while the filter is active — a blank cell satisfies no\n   * comparison, not even `neq`, which would otherwise quietly include every empty row.\n   */\n  matches(value: unknown): boolean {\n    if (!this.active) return true;\n\n    const n = toNumber(value);\n    if (n === undefined) return false;\n\n    const operand = this.operand;\n    if (isIntervalOp(this.op)) {\n      const bounds = toBounds(operand);\n      if (!bounds) return true;\n      // An open end is simply unbounded, so a half-filled range control means what it looks like it\n      // means: only a lower bound reads as \"and up\".\n      const { min, max } = bounds;\n      const inclusive = this.op === \"between\";\n      if (min !== undefined && (inclusive ? n < min : n <= min)) return false;\n      if (max !== undefined && (inclusive ? n > max : n >= max)) return false;\n      return true;\n    }\n    if (typeof operand !== \"number\") return true;\n\n    switch (this.op as UnaryNumberOp) {\n      case \"eq\":\n        return n === operand;\n      case \"neq\":\n        return n !== operand;\n      case \"gt\":\n        return n > operand;\n      case \"lt\":\n        return n < operand;\n      case \"gte\":\n        return n >= operand;\n      default:\n        return n <= operand;\n    }\n  }\n\n  /** Change the operator alone. May leave the filter inactive — see {@link active}. */\n  setOp(op: NumberOp): void {\n    this.op = op;\n  }\n\n  setOperand(operand: number | NumberBounds | undefined): void {\n    this.operand = toBounds(operand) ?? operand;\n  }\n\n  /**\n   * Set the lower bound alone, leaving the upper one where it is. A no-op under a unary operator,\n   * where a UI renders one input rather than two.\n   */\n  setMin(min: number | undefined): void {\n    if (!isIntervalOp(this.op)) return;\n    this.setRange(min, this.max);\n  }\n\n  /** Set the upper bound alone, leaving the lower one where it is. */\n  setMax(max: number | undefined): void {\n    if (!isIntervalOp(this.op)) return;\n    this.setRange(this.min, max);\n  }\n\n  /** Set both bounds at once. A no-op under a unary operator. Mirrors `DateFilter.setRange`. */\n  setRange(min: number | undefined, max: number | undefined): void {\n    if (!isIntervalOp(this.op)) return;\n    const bounds: NumberBounds = {};\n    if (bound(min) !== undefined) bounds.min = bound(min);\n    if (bound(max) !== undefined) bounds.max = bound(max);\n    this.operand = bounds;\n  }\n\n  /** Change operator and operand together, which is what an operator dropdown should do. */\n  set(op: UnaryNumberOp, operand?: number): void;\n  set(op: IntervalNumberOp, operand?: NumberBounds): void;\n  set(op: NumberOp, operand?: number | NumberBounds): void {\n    this.op = op;\n    this.setOperand(operand);\n  }\n\n  /**\n   * Restore state from {@link value}. An operand that doesn't fit the operator is dropped rather\n   * than trusted, so a snapshot written when the operator was `between` leaves a `gte` inactive\n   * instead of comparing against a pair.\n   */\n  setValue(value?: unknown): void {\n    const state = (value ?? {}) as Partial<NumberFilterState>;\n    const op = state.op;\n    this.op = typeof op === \"string\" ? op : \"eq\";\n    if (isIntervalOp(this.op)) {\n      const bounds = toBounds(state.operand);\n      this.operand =\n        bounds && (bounds.min !== undefined || bounds.max !== undefined) ? bounds : undefined;\n    } else {\n      this.operand = bound(state.operand);\n    }\n  }\n\n  /** Clear the operand. The operator is left alone — it is a choice the user made. */\n  clear(): void {\n    this.operand = undefined;\n  }\n}\n","import { action, computed, makeObservable, observable } from \"mobx\";\nimport type {\n  FilterCondition,\n  TextFilterOptions,\n  TextFilterProps,\n  TextMatchMode,\n  ValueFilter,\n} from \"./filter.types\";\nimport { textMatches } from \"./util\";\n\n/**\n * A single-value text filter — the \"contains\" box that lives on one column.\n *\n * Distinct from a table's cross-column search, which needs every column's accessor at once and so\n * cannot be a value predicate. Both go through `textMatches`, so they compare identically.\n *\n * `match` and `caseSensitive` are configuration rather than state: unlike a set filter's\n * `matchMode`, they are not things a UI typically hands to the user.\n */\nexport class TextFilter implements ValueFilter {\n  text = \"\";\n\n  /**\n   * Whatever your components need to render this filter. Empty until you augment\n   * {@link TextFilterProps} — the library never reads it.\n   */\n  readonly props: TextFilterProps;\n\n  readonly match: TextMatchMode;\n  readonly caseSensitive: boolean;\n\n  get active(): boolean {\n    return this.text !== \"\";\n  }\n\n  /** JSON-serializable state; round-trips through {@link setValue}. */\n  get value(): string {\n    return this.text;\n  }\n\n  /**\n   * The query as a server condition. The op is the configured `match`, so `\"contains\"` /\n   * `\"startsWith\"` / `\"equals\"` carry across unchanged. `undefined` while inactive.\n   *\n   * `caseSensitive` is deliberately not serialized: it describes how *this* process compares, and\n   * a server's collation is its own business.\n   */\n  get condition(): FilterCondition | undefined {\n    if (this.text === \"\") return undefined;\n    return { op: this.match, value: this.text };\n  }\n\n  constructor(options?: TextFilterOptions) {\n    this.text = options?.text ?? \"\";\n    this.match = options?.match ?? \"contains\";\n    this.caseSensitive = options?.caseSensitive === true;\n\n    this.props = options?.props ?? {};\n\n    makeObservable(this, {\n      text: observable,\n\n      active: computed,\n      value: computed,\n      condition: computed,\n\n      setText: action.bound,\n      setValue: action.bound,\n      clear: action.bound,\n    });\n  }\n\n  matches(value: unknown): boolean {\n    return textMatches(this.text, value, this.match, this.caseSensitive);\n  }\n\n  setText(text: string): void {\n    this.text = text;\n  }\n\n  /** Restore state from {@link value}. Anything that is not a string clears the query. */\n  setValue(value?: unknown): void {\n    this.text = typeof value === \"string\" ? value : \"\";\n  }\n\n  clear(): void {\n    this.text = \"\";\n  }\n}\n"],"mappings":"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"}