elastic-input 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -455,6 +455,40 @@ Pass `HighlightOptions` for matched-paren highlighting:
455
455
  buildHighlightedHTML(tokens, DEFAULT_COLORS, { cursorOffset: 5 });
456
456
  ```
457
457
 
458
+ ## Query Formatting
459
+
460
+ Pretty-print messy or minified queries with `formatQuery` — a pure function (no React or DOM required):
461
+
462
+ ```typescript
463
+ import { formatQuery } from 'elastic-input';
464
+
465
+ formatQuery('(status:active OR status:lead) AND deal_value:>5000 AND NOT tags:churned');
466
+ // (status:active OR status:lead)
467
+ // AND deal_value:>5000
468
+ // AND NOT tags:churned
469
+
470
+ formatQuery('( (status:active AND deal_value:>10000) OR (status:lead AND tags:enterprise) ) AND created:[2024-01-01 TO 2024-12-31]');
471
+ // (
472
+ // status:active AND deal_value:>10000
473
+ // OR status:lead AND tags:enterprise
474
+ // )
475
+ // AND created:[2024-01-01 TO 2024-12-31]
476
+ ```
477
+
478
+ Accepts a raw query string or a pre-parsed `ASTNode`. Options control line-break threshold and indentation:
479
+
480
+ ```typescript
481
+ import type { FormatQueryOptions } from 'elastic-input';
482
+
483
+ formatQuery(query, { maxLineLength: 80, indent: '\t' });
484
+ ```
485
+
486
+ | Option | Type | Default | Description |
487
+ |--------|------|---------|-------------|
488
+ | `maxLineLength` | `number` | `60` | Lines shorter than this stay inline |
489
+ | `indent` | `string` | `' '` (2 spaces) | Indent string per nesting level |
490
+ | `whitespaceOperator` | `string` | — | Replace implicit AND (whitespace) with this operator (e.g. `'AND'`, `'&&'`) |
491
+
458
492
  ## Requirements
459
493
 
460
494
  ### Runtime (Browser)
@@ -19,6 +19,8 @@ interface AutocompleteDropdownProps {
19
19
  renderSavedSearchItem?: (search: SavedSearch, isSelected: boolean) => React.ReactNode | null | undefined;
20
20
  renderDropdownHeader?: (context: CursorContext) => React.ReactNode | null | undefined;
21
21
  cursorContext?: CursorContext | null;
22
+ /** Ref callback to expose the dropdown list element for page-size calculations. */
23
+ listRefCallback?: (el: HTMLDivElement | null) => void;
22
24
  /** Custom class names for dropdown elements. */
23
25
  classNames?: {
24
26
  dropdown?: string;
@@ -26,5 +28,5 @@ interface AutocompleteDropdownProps {
26
28
  dropdownItem?: string;
27
29
  };
28
30
  }
29
- export declare function AutocompleteDropdown({ suggestions, selectedIndex, onSelect, position, colors, styles, visible, fixedWidth, renderHistoryItem, renderSavedSearchItem, renderDropdownHeader, cursorContext, classNames, }: AutocompleteDropdownProps): React.ReactPortal | null;
31
+ export declare function AutocompleteDropdown({ suggestions, selectedIndex, onSelect, position, colors, styles, visible, fixedWidth, renderHistoryItem, renderSavedSearchItem, renderDropdownHeader, cursorContext, listRefCallback, classNames, }: AutocompleteDropdownProps): React.ReactPortal | null;
30
32
  export {};
@@ -444,6 +444,7 @@ class Parser {
444
444
  operator: "AND",
445
445
  left: result,
446
446
  right,
447
+ implicit: true,
447
448
  start: result.start,
448
449
  end: right.end
449
450
  };
@@ -526,6 +527,7 @@ class Parser {
526
527
  operator: "AND",
527
528
  left,
528
529
  right,
530
+ implicit: true,
529
531
  start: left.start,
530
532
  end: right.end
531
533
  };
@@ -2749,6 +2751,7 @@ function AutocompleteDropdown({
2749
2751
  renderSavedSearchItem,
2750
2752
  renderDropdownHeader,
2751
2753
  cursorContext,
2754
+ listRefCallback,
2752
2755
  classNames
2753
2756
  }) {
2754
2757
  const portalRef = React.useRef(null);
@@ -2784,7 +2787,10 @@ function AutocompleteDropdown({
2784
2787
  left: `${position.left}px`,
2785
2788
  ...fixedWidth != null ? { width: `${fixedWidth}px`, minWidth: "unset", maxWidth: "unset" } : {}
2786
2789
  };
2787
- const content = /* @__PURE__ */ React.createElement("div", { className: cx("ei-dropdown", classNames == null ? void 0 : classNames.dropdown), style: dropdownStyle, ref: listRef, onMouseDown: (e) => e.preventDefault() }, hasHeader && /* @__PURE__ */ React.createElement("div", { className: cx("ei-dropdown-header", classNames == null ? void 0 : classNames.dropdownHeader), style: {
2790
+ const content = /* @__PURE__ */ React.createElement("div", { className: cx("ei-dropdown", classNames == null ? void 0 : classNames.dropdown), style: dropdownStyle, ref: (el) => {
2791
+ listRef.current = el;
2792
+ listRefCallback == null ? void 0 : listRefCallback(el);
2793
+ }, onMouseDown: (e) => e.preventDefault() }, hasHeader && /* @__PURE__ */ React.createElement("div", { className: cx("ei-dropdown-header", classNames == null ? void 0 : classNames.dropdownHeader), style: {
2788
2794
  padding: mergedStyles.dropdownItemPadding || "4px 10px",
2789
2795
  fontSize: "11px",
2790
2796
  color: mergedColors.placeholder,
@@ -2817,6 +2823,9 @@ function AutocompleteDropdown({
2817
2823
  if (suggestion.type === "error") {
2818
2824
  return /* @__PURE__ */ React.createElement("div", { key: i, className: cx("ei-dropdown-item", "ei-dropdown-item--error", classNames == null ? void 0 : classNames.dropdownItem), style: { ...itemStyle, cursor: "default", opacity: 0.8 } }, /* @__PURE__ */ React.createElement("span", { className: "ei-dropdown-item-label", style: { ...getDropdownItemLabelStyle(), color: mergedColors.error } }, suggestion.label || "Error loading suggestions"));
2819
2825
  }
2826
+ if (suggestion.type === "noResults") {
2827
+ return /* @__PURE__ */ React.createElement("div", { key: i, className: cx("ei-dropdown-item", "ei-dropdown-item--no-results", classNames == null ? void 0 : classNames.dropdownItem), style: { ...itemStyle, cursor: "default", opacity: 0.7 } }, suggestion.customContent);
2828
+ }
2820
2829
  if (suggestion.type === "loading") {
2821
2830
  return /* @__PURE__ */ React.createElement("div", { key: i, className: cx("ei-dropdown-item", "ei-dropdown-item--loading", classNames == null ? void 0 : classNames.dropdownItem), style: { ...itemStyle, cursor: "default", opacity: 0.6, justifyContent: "center" } }, /* @__PURE__ */ React.createElement("span", { className: "ei-dropdown-item-label", style: { ...getDropdownItemLabelStyle(), fontStyle: "italic" } }, suggestion.label || "Searching..."), /* @__PURE__ */ React.createElement("span", { className: "ei-dropdown-spinner", style: { marginLeft: "6px", display: "inline-block", animation: "elastic-input-spin 1s linear infinite", width: "14px", height: "14px", border: "2px solid", borderColor: `${mergedColors.placeholder} transparent ${mergedColors.placeholder} transparent`, borderRadius: "50%" } }));
2822
2831
  }
@@ -3622,6 +3631,155 @@ function dedup(ranges) {
3622
3631
  }
3623
3632
  return result;
3624
3633
  }
3634
+ const DEFAULT_MAX_LINE_LENGTH = 60;
3635
+ const DEFAULT_INDENT = " ";
3636
+ function formatQuery(input, options) {
3637
+ const maxLineLength = (options == null ? void 0 : options.maxLineLength) ?? DEFAULT_MAX_LINE_LENGTH;
3638
+ const indent = (options == null ? void 0 : options.indent) ?? DEFAULT_INDENT;
3639
+ const whitespaceOperator = options == null ? void 0 : options.whitespaceOperator;
3640
+ let ast;
3641
+ if (typeof input === "string") {
3642
+ const tokens = new Lexer(input, { savedSearches: true, historySearch: true }).tokenize();
3643
+ const parser = new Parser(tokens);
3644
+ ast = parser.parse();
3645
+ } else {
3646
+ ast = input;
3647
+ }
3648
+ if (!ast) return typeof input === "string" ? input : "";
3649
+ return printNode(ast, 0, maxLineLength, indent, whitespaceOperator);
3650
+ }
3651
+ function resolveOperator(node, whitespaceOperator) {
3652
+ if (node.implicit) return whitespaceOperator ?? "";
3653
+ return node.operator;
3654
+ }
3655
+ function inline(node, whitespaceOperator) {
3656
+ switch (node.type) {
3657
+ case "FieldValue": {
3658
+ const val = node.quoted ? `"${node.value}"` : node.value;
3659
+ const op = node.operator === ":" ? ":" : `:${node.operator}`;
3660
+ let s = `${node.field}${op}${val}`;
3661
+ if (node.fuzzy != null) s += `~${node.fuzzy}`;
3662
+ if (node.proximity != null) s += `~${node.proximity}`;
3663
+ if (node.boost != null) s += `^${node.boost}`;
3664
+ return s;
3665
+ }
3666
+ case "BareTerm": {
3667
+ let s = node.quoted ? `"${node.value}"` : node.value;
3668
+ if (node.fuzzy != null) s += `~${node.fuzzy}`;
3669
+ if (node.proximity != null) s += `~${node.proximity}`;
3670
+ if (node.boost != null) s += `^${node.boost}`;
3671
+ return s;
3672
+ }
3673
+ case "Range": {
3674
+ const lb = node.lowerInclusive ? "[" : "{";
3675
+ const rb = node.upperInclusive ? "]" : "}";
3676
+ const lower = node.lowerQuoted ? `"${node.lower}"` : node.lower;
3677
+ const upper = node.upperQuoted ? `"${node.upper}"` : node.upper;
3678
+ const range = `${lb}${lower} TO ${upper}${rb}`;
3679
+ return node.field ? `${node.field}:${range}` : range;
3680
+ }
3681
+ case "Regex":
3682
+ return `/${node.pattern}/`;
3683
+ case "SavedSearch":
3684
+ return `#${node.name}`;
3685
+ case "HistoryRef":
3686
+ return `!${node.ref}`;
3687
+ case "Not":
3688
+ return `NOT ${inline(node.expression, whitespaceOperator)}`;
3689
+ case "Group": {
3690
+ let s = `(${inline(node.expression, whitespaceOperator)})`;
3691
+ if (node.boost != null) s += `^${node.boost}`;
3692
+ return s;
3693
+ }
3694
+ case "FieldGroup": {
3695
+ let s = `${node.field}:(${inline(node.expression, whitespaceOperator)})`;
3696
+ if (node.boost != null) s += `^${node.boost}`;
3697
+ return s;
3698
+ }
3699
+ case "BooleanExpr": {
3700
+ const op = resolveOperator(node, whitespaceOperator);
3701
+ const sep = op ? ` ${op} ` : " ";
3702
+ return `${inline(node.left, whitespaceOperator)}${sep}${inline(node.right, whitespaceOperator)}`;
3703
+ }
3704
+ case "Error":
3705
+ return node.value;
3706
+ }
3707
+ }
3708
+ function flattenChain(node) {
3709
+ const op = node.operator;
3710
+ const implicit = !!node.implicit;
3711
+ const operands = [];
3712
+ const collect = (n) => {
3713
+ if (n.type === "BooleanExpr" && n.operator === op && !!n.implicit === implicit) {
3714
+ collect(n.left);
3715
+ collect(n.right);
3716
+ } else {
3717
+ operands.push(n);
3718
+ }
3719
+ };
3720
+ collect(node);
3721
+ return { operator: op, implicit, operands };
3722
+ }
3723
+ function containsGroups(node) {
3724
+ if (node.type === "Group" || node.type === "FieldGroup") return true;
3725
+ if (node.type === "BooleanExpr") return containsGroups(node.left) || containsGroups(node.right);
3726
+ if (node.type === "Not") return containsGroups(node.expression);
3727
+ return false;
3728
+ }
3729
+ function shouldBreakGroup(expr, maxLineLength) {
3730
+ const inlined = inline(expr);
3731
+ if (inlined.length > maxLineLength) return true;
3732
+ if (containsGroups(expr)) return true;
3733
+ return false;
3734
+ }
3735
+ function printNode(node, depth, maxLineLength, indent, whitespaceOperator) {
3736
+ const pad = indent.repeat(depth);
3737
+ switch (node.type) {
3738
+ case "BooleanExpr": {
3739
+ const { operator, implicit, operands } = flattenChain(node);
3740
+ const displayOp = implicit ? whitespaceOperator ?? "" : operator;
3741
+ const sep = displayOp ? ` ${displayOp} ` : " ";
3742
+ const inlined = operands.map((o) => inline(o, whitespaceOperator)).join(sep);
3743
+ if (inlined.length <= maxLineLength) {
3744
+ return inlined;
3745
+ }
3746
+ const lines = operands.map((operand, i) => {
3747
+ const printed = printNode(operand, depth, maxLineLength, indent, whitespaceOperator);
3748
+ if (i === 0) return printed;
3749
+ return displayOp ? `${pad}${displayOp} ${printed}` : `${pad}${printed}`;
3750
+ });
3751
+ return lines.join("\n");
3752
+ }
3753
+ case "Group": {
3754
+ if (!shouldBreakGroup(node.expression, maxLineLength)) {
3755
+ let s2 = `(${inline(node.expression, whitespaceOperator)})`;
3756
+ if (node.boost != null) s2 += `^${node.boost}`;
3757
+ return s2;
3758
+ }
3759
+ const inner = printNode(node.expression, depth + 1, maxLineLength, indent, whitespaceOperator);
3760
+ let s = `(
3761
+ ${indentLines(inner, depth + 1, indent)}
3762
+ ${pad})`;
3763
+ if (node.boost != null) s += `^${node.boost}`;
3764
+ return s;
3765
+ }
3766
+ case "FieldGroup": {
3767
+ let s = `${node.field}:(${inline(node.expression, whitespaceOperator)})`;
3768
+ if (node.boost != null) s += `^${node.boost}`;
3769
+ return s;
3770
+ }
3771
+ case "Not":
3772
+ return `NOT ${printNode(node.expression, depth, maxLineLength, indent, whitespaceOperator)}`;
3773
+ default:
3774
+ return inline(node, whitespaceOperator);
3775
+ }
3776
+ }
3777
+ function indentLines(text, depth, indent) {
3778
+ const pad = indent.repeat(depth);
3779
+ return text.split("\n").map((line) => {
3780
+ return line.startsWith(pad) ? line : pad + line;
3781
+ }).join("\n");
3782
+ }
3625
3783
  class UndoStack {
3626
3784
  constructor(maxSize = 100) {
3627
3785
  __publicField(this, "stack", []);
@@ -3802,11 +3960,14 @@ function ElasticInput(props) {
3802
3960
  const renderHistoryItem = dropdownConfig == null ? void 0 : dropdownConfig.renderHistoryItem;
3803
3961
  const renderSavedSearchItem = dropdownConfig == null ? void 0 : dropdownConfig.renderSavedSearchItem;
3804
3962
  const renderDropdownHeader = dropdownConfig == null ? void 0 : dropdownConfig.renderHeader;
3963
+ const renderNoResults = dropdownConfig == null ? void 0 : dropdownConfig.renderNoResults;
3805
3964
  const autoSelect = (dropdownConfig == null ? void 0 : dropdownConfig.autoSelect) ?? false;
3965
+ const homeEndKeys = (dropdownConfig == null ? void 0 : dropdownConfig.homeEndKeys) ?? false;
3806
3966
  const multiline = (featuresConfig == null ? void 0 : featuresConfig.multiline) !== false;
3807
3967
  const smartSelectAll = (featuresConfig == null ? void 0 : featuresConfig.smartSelectAll) ?? false;
3808
3968
  const expandSelection = (featuresConfig == null ? void 0 : featuresConfig.expandSelection) ?? false;
3809
3969
  const wildcardWrap = (featuresConfig == null ? void 0 : featuresConfig.wildcardWrap) ?? false;
3970
+ const enableFormatQuery = (featuresConfig == null ? void 0 : featuresConfig.formatQuery) ?? false;
3810
3971
  const lexerOptions = React.useMemo(() => ({
3811
3972
  savedSearches: enableSavedSearches,
3812
3973
  historySearch: enableHistorySearch
@@ -3845,9 +4006,11 @@ function ElasticInput(props) {
3845
4006
  setEditorEl(el);
3846
4007
  }, []);
3847
4008
  const containerRef = React.useRef(null);
4009
+ const dropdownListRef = React.useRef(null);
3848
4010
  const currentValueRef = React.useRef(value || defaultValue || "");
3849
4011
  const debounceTimerRef = React.useRef(null);
3850
4012
  const isComposingRef = React.useRef(false);
4013
+ const keyConsumedByDropdownRef = React.useRef(false);
3851
4014
  const undoStackRef = React.useRef(new UndoStack());
3852
4015
  const typingGroupTimerRef = React.useRef(null);
3853
4016
  const abortControllerRef = React.useRef(null);
@@ -4037,6 +4200,24 @@ function ElasticInput(props) {
4037
4200
  return { ...s, customContent: custom };
4038
4201
  });
4039
4202
  }, [renderFieldHint]);
4203
+ const tryShowNoResults = React.useCallback((context) => {
4204
+ if (!renderNoResults) return false;
4205
+ const content = renderNoResults({ cursorContext: context, partial: context.partial });
4206
+ if (content == null) return false;
4207
+ const noResultsSuggestion = {
4208
+ text: "",
4209
+ label: "",
4210
+ type: "noResults",
4211
+ customContent: content,
4212
+ replaceStart: 0,
4213
+ replaceEnd: 0
4214
+ };
4215
+ setSuggestions([noResultsSuggestion]);
4216
+ setSelectedSuggestionIndex(-1);
4217
+ setShowDatePicker(false);
4218
+ showDropdownAtPosition(32, 300);
4219
+ return true;
4220
+ }, [renderNoResults]);
4040
4221
  const updateSuggestionsFromTokens = React.useCallback((toks, offset) => {
4041
4222
  var _a, _b, _c;
4042
4223
  const result = engineRef.current.getSuggestions(toks, offset);
@@ -4131,10 +4312,12 @@ function ElasticInput(props) {
4131
4312
  setAutocompleteContext(contextType);
4132
4313
  showDropdownAtPosition(newSuggestions.length * 32, 300);
4133
4314
  } else {
4134
- setShowDropdown(false);
4135
- setShowDatePicker(false);
4136
- setSuggestions([]);
4137
4315
  setAutocompleteContext(contextType);
4316
+ if (!tryShowNoResults(result.context)) {
4317
+ setShowDropdown(false);
4318
+ setShowDatePicker(false);
4319
+ setSuggestions([]);
4320
+ }
4138
4321
  }
4139
4322
  } else {
4140
4323
  const token = result.context.token;
@@ -4230,17 +4413,20 @@ function ElasticInput(props) {
4230
4413
  showDropdownAtPosition(mapped.length * 32, 300);
4231
4414
  } else {
4232
4415
  const syncResult = engineRef.current.getSuggestions(stateRef.current.tokens, stateRef.current.cursorOffset);
4233
- const hintSuggestions = applyFieldHint(
4234
- syncResult.suggestions.filter((s) => s.type === "hint"),
4235
- syncResult.context
4236
- );
4237
- if (hintSuggestions.length > 0) {
4238
- setSuggestions(hintSuggestions);
4239
- setSelectedSuggestionIndex(syncResult.context.partial || autoSelect ? 0 : -1);
4240
- showDropdownAtPosition(hintSuggestions.length * 32, 300);
4416
+ if (partial && tryShowNoResults(syncResult.context)) {
4241
4417
  } else {
4242
- setShowDropdown(false);
4243
- setSuggestions([]);
4418
+ const hintSuggestions = applyFieldHint(
4419
+ syncResult.suggestions.filter((s) => s.type === "hint"),
4420
+ syncResult.context
4421
+ );
4422
+ if (hintSuggestions.length > 0) {
4423
+ setSuggestions(hintSuggestions);
4424
+ setSelectedSuggestionIndex(syncResult.context.partial || autoSelect ? 0 : -1);
4425
+ showDropdownAtPosition(hintSuggestions.length * 32, 300);
4426
+ } else if (!tryShowNoResults(syncResult.context)) {
4427
+ setShowDropdown(false);
4428
+ setSuggestions([]);
4429
+ }
4244
4430
  }
4245
4431
  }
4246
4432
  } catch (e) {
@@ -4265,7 +4451,7 @@ function ElasticInput(props) {
4265
4451
  }
4266
4452
  }, debounceMs);
4267
4453
  }
4268
- }, [fetchSuggestionsProp, savedSearches, searchHistory, suggestDebounceMs, applyFieldHint, computeDropdownPosition, showDropdownAtPosition, dropdownAlignToInput, dropdownOpen, dropdownOpenIsCallback, dropdownMode, showOperators, effectiveMaxSuggestions, loadingDelay, autoSelect]);
4454
+ }, [fetchSuggestionsProp, savedSearches, searchHistory, suggestDebounceMs, applyFieldHint, computeDropdownPosition, showDropdownAtPosition, dropdownAlignToInput, dropdownOpen, dropdownOpenIsCallback, dropdownMode, showOperators, effectiveMaxSuggestions, loadingDelay, autoSelect, tryShowNoResults]);
4269
4455
  updateSuggestionsRef.current = updateSuggestionsFromTokens;
4270
4456
  const closeDropdown = React.useCallback(() => {
4271
4457
  var _a;
@@ -4613,11 +4799,30 @@ function ElasticInput(props) {
4613
4799
  if (onChange) onChange(entry.value, newAst);
4614
4800
  if (onValidationChange) onValidationChange(newErrors);
4615
4801
  }, [colors, onChange, onValidationChange, closeDropdown]);
4802
+ const getDropdownPageSize = React.useCallback(() => {
4803
+ const list = dropdownListRef.current;
4804
+ if (!list) return 10;
4805
+ const visibleHeight = list.clientHeight;
4806
+ const items = list.querySelectorAll(".ei-dropdown-item");
4807
+ const firstItem = items[0];
4808
+ if (!firstItem) return 10;
4809
+ const itemHeight = firstItem.offsetHeight;
4810
+ if (itemHeight <= 0) return 10;
4811
+ return Math.max(1, Math.floor(visibleHeight / itemHeight));
4812
+ }, []);
4616
4813
  const handleKeyDown = React.useCallback((e) => {
4617
4814
  var _a, _b;
4618
4815
  if (onKeyDownProp) onKeyDownProp(e);
4619
4816
  if (e.defaultPrevented) return;
4620
4817
  const s = stateRef.current;
4818
+ if (enableFormatQuery && e.key === "F" && e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey) {
4819
+ e.preventDefault();
4820
+ const formatted = formatQuery(currentValueRef.current);
4821
+ if (formatted !== currentValueRef.current) {
4822
+ applyNewValue(formatted, formatted.length);
4823
+ }
4824
+ return;
4825
+ }
4621
4826
  if (editorRef.current && editorRef.current.childNodes.length > 40) {
4622
4827
  const sel = window.getSelection();
4623
4828
  const hasSelection = sel != null && !sel.isCollapsed;
@@ -4768,24 +4973,49 @@ function ElasticInput(props) {
4768
4973
  handleInput();
4769
4974
  return;
4770
4975
  }
4976
+ keyConsumedByDropdownRef.current = false;
4771
4977
  if (s.showDropdown && s.suggestions.length > 0) {
4772
4978
  switch (e.key) {
4773
4979
  case "ArrowDown":
4774
4980
  e.preventDefault();
4981
+ keyConsumedByDropdownRef.current = true;
4775
4982
  setSelectedSuggestionIndex((i) => i >= s.suggestions.length - 1 ? 0 : i + 1);
4776
4983
  return;
4777
4984
  case "ArrowUp":
4778
4985
  e.preventDefault();
4986
+ keyConsumedByDropdownRef.current = true;
4779
4987
  setSelectedSuggestionIndex((i) => i <= 0 ? s.suggestions.length - 1 : i - 1);
4780
4988
  return;
4781
- case "PageDown":
4989
+ case "PageDown": {
4782
4990
  e.preventDefault();
4783
- setSelectedSuggestionIndex((i) => Math.min(i + 10, s.suggestions.length - 1));
4991
+ keyConsumedByDropdownRef.current = true;
4992
+ const pageSize = getDropdownPageSize();
4993
+ setSelectedSuggestionIndex((i) => Math.min(i + pageSize, s.suggestions.length - 1));
4784
4994
  return;
4785
- case "PageUp":
4995
+ }
4996
+ case "PageUp": {
4786
4997
  e.preventDefault();
4787
- setSelectedSuggestionIndex((i) => Math.max(i - 10, 0));
4998
+ keyConsumedByDropdownRef.current = true;
4999
+ const pageSize = getDropdownPageSize();
5000
+ setSelectedSuggestionIndex((i) => Math.max(i - pageSize, 0));
4788
5001
  return;
5002
+ }
5003
+ case "Home":
5004
+ if (homeEndKeys && s.selectedSuggestionIndex >= 0) {
5005
+ e.preventDefault();
5006
+ keyConsumedByDropdownRef.current = true;
5007
+ setSelectedSuggestionIndex(0);
5008
+ return;
5009
+ }
5010
+ break;
5011
+ case "End":
5012
+ if (homeEndKeys && s.selectedSuggestionIndex >= 0) {
5013
+ e.preventDefault();
5014
+ keyConsumedByDropdownRef.current = true;
5015
+ setSelectedSuggestionIndex(s.suggestions.length - 1);
5016
+ return;
5017
+ }
5018
+ break;
4789
5019
  case "Enter":
4790
5020
  if (s.selectedSuggestionIndex >= 0) {
4791
5021
  const selected = s.suggestions[s.selectedSuggestionIndex];
@@ -4889,8 +5119,12 @@ function ElasticInput(props) {
4889
5119
  if (onSearch) onSearch(currentValueRef.current, s.ast);
4890
5120
  return;
4891
5121
  }
4892
- }, [onSearch, closeDropdown, acceptSuggestion, applyNewValue, restoreUndoEntry, multiline, dropdownOpenIsCallback, dropdownMode, updateSuggestionsFromTokens, onKeyDownProp, onTabProp, smartSelectAll, expandSelection]);
5122
+ }, [onSearch, closeDropdown, acceptSuggestion, applyNewValue, restoreUndoEntry, multiline, dropdownOpenIsCallback, dropdownMode, updateSuggestionsFromTokens, onKeyDownProp, onTabProp, smartSelectAll, expandSelection, homeEndKeys, getDropdownPageSize, enableFormatQuery]);
4893
5123
  const handleKeyUp = React.useCallback((e) => {
5124
+ if (keyConsumedByDropdownRef.current) {
5125
+ keyConsumedByDropdownRef.current = false;
5126
+ return;
5127
+ }
4894
5128
  const navKeys = ["ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"];
4895
5129
  if (navKeys.includes(e.key)) {
4896
5130
  if (!editorRef.current) return;
@@ -5034,6 +5268,9 @@ function ElasticInput(props) {
5034
5268
  renderSavedSearchItem,
5035
5269
  renderDropdownHeader,
5036
5270
  cursorContext,
5271
+ listRefCallback: (el) => {
5272
+ dropdownListRef.current = el;
5273
+ },
5037
5274
  classNames: classNames ? { dropdown: classNames.dropdown, dropdownHeader: classNames.dropdownHeader, dropdownItem: classNames.dropdownItem } : void 0
5038
5275
  }
5039
5276
  ), showDatePicker && dropdownPosition ? /* @__PURE__ */ React.createElement(
@@ -5125,141 +5362,6 @@ function walk(node, groupField, out) {
5125
5362
  break;
5126
5363
  }
5127
5364
  }
5128
- const INLINE_MAX_LENGTH = 60;
5129
- const INDENT = " ";
5130
- function formatQuery(input) {
5131
- let ast;
5132
- if (typeof input === "string") {
5133
- const tokens = new Lexer(input, { savedSearches: true, historySearch: true }).tokenize();
5134
- const parser = new Parser(tokens);
5135
- ast = parser.parse();
5136
- } else {
5137
- ast = input;
5138
- }
5139
- if (!ast) return typeof input === "string" ? input : "";
5140
- return printNode(ast, 0);
5141
- }
5142
- function inline(node) {
5143
- switch (node.type) {
5144
- case "FieldValue": {
5145
- const val = node.quoted ? `"${node.value}"` : node.value;
5146
- const op = node.operator === ":" ? ":" : `:${node.operator}`;
5147
- let s = `${node.field}${op}${val}`;
5148
- if (node.fuzzy != null) s += `~${node.fuzzy}`;
5149
- if (node.proximity != null) s += `~${node.proximity}`;
5150
- if (node.boost != null) s += `^${node.boost}`;
5151
- return s;
5152
- }
5153
- case "BareTerm": {
5154
- let s = node.quoted ? `"${node.value}"` : node.value;
5155
- if (node.fuzzy != null) s += `~${node.fuzzy}`;
5156
- if (node.proximity != null) s += `~${node.proximity}`;
5157
- if (node.boost != null) s += `^${node.boost}`;
5158
- return s;
5159
- }
5160
- case "Range": {
5161
- const lb = node.lowerInclusive ? "[" : "{";
5162
- const rb = node.upperInclusive ? "]" : "}";
5163
- const lower = node.lowerQuoted ? `"${node.lower}"` : node.lower;
5164
- const upper = node.upperQuoted ? `"${node.upper}"` : node.upper;
5165
- const range = `${lb}${lower} TO ${upper}${rb}`;
5166
- return node.field ? `${node.field}:${range}` : range;
5167
- }
5168
- case "Regex":
5169
- return `/${node.pattern}/`;
5170
- case "SavedSearch":
5171
- return `#${node.name}`;
5172
- case "HistoryRef":
5173
- return `!${node.ref}`;
5174
- case "Not":
5175
- return `NOT ${inline(node.expression)}`;
5176
- case "Group": {
5177
- let s = `(${inline(node.expression)})`;
5178
- if (node.boost != null) s += `^${node.boost}`;
5179
- return s;
5180
- }
5181
- case "FieldGroup": {
5182
- let s = `${node.field}:(${inline(node.expression)})`;
5183
- if (node.boost != null) s += `^${node.boost}`;
5184
- return s;
5185
- }
5186
- case "BooleanExpr":
5187
- return `${inline(node.left)} ${node.operator} ${inline(node.right)}`;
5188
- case "Error":
5189
- return node.value;
5190
- }
5191
- }
5192
- function flattenChain(node) {
5193
- const op = node.operator;
5194
- const operands = [];
5195
- const collect = (n) => {
5196
- if (n.type === "BooleanExpr" && n.operator === op) {
5197
- collect(n.left);
5198
- collect(n.right);
5199
- } else {
5200
- operands.push(n);
5201
- }
5202
- };
5203
- collect(node);
5204
- return { operator: op, operands };
5205
- }
5206
- function containsGroups(node) {
5207
- if (node.type === "Group" || node.type === "FieldGroup") return true;
5208
- if (node.type === "BooleanExpr") return containsGroups(node.left) || containsGroups(node.right);
5209
- if (node.type === "Not") return containsGroups(node.expression);
5210
- return false;
5211
- }
5212
- function shouldBreakGroup(expr) {
5213
- const inlined = inline(expr);
5214
- if (inlined.length > INLINE_MAX_LENGTH) return true;
5215
- if (containsGroups(expr)) return true;
5216
- return false;
5217
- }
5218
- function printNode(node, depth) {
5219
- const pad = INDENT.repeat(depth);
5220
- switch (node.type) {
5221
- case "BooleanExpr": {
5222
- const { operator, operands } = flattenChain(node);
5223
- const inlined = operands.map((o) => inline(o)).join(` ${operator} `);
5224
- if (inlined.length <= INLINE_MAX_LENGTH) {
5225
- return inlined;
5226
- }
5227
- const lines = operands.map((operand, i) => {
5228
- const printed = printNode(operand, depth);
5229
- return i === 0 ? printed : `${pad}${operator} ${printed}`;
5230
- });
5231
- return lines.join("\n");
5232
- }
5233
- case "Group": {
5234
- if (!shouldBreakGroup(node.expression)) {
5235
- let s2 = `(${inline(node.expression)})`;
5236
- if (node.boost != null) s2 += `^${node.boost}`;
5237
- return s2;
5238
- }
5239
- const inner = printNode(node.expression, depth + 1);
5240
- let s = `(
5241
- ${indentLines(inner, depth + 1)}
5242
- ${pad})`;
5243
- if (node.boost != null) s += `^${node.boost}`;
5244
- return s;
5245
- }
5246
- case "FieldGroup": {
5247
- let s = `${node.field}:(${inline(node.expression)})`;
5248
- if (node.boost != null) s += `^${node.boost}`;
5249
- return s;
5250
- }
5251
- case "Not":
5252
- return `NOT ${printNode(node.expression, depth)}`;
5253
- default:
5254
- return inline(node);
5255
- }
5256
- }
5257
- function indentLines(text, depth) {
5258
- const pad = INDENT.repeat(depth);
5259
- return text.split("\n").map((line) => {
5260
- return line.startsWith(pad) ? line : pad + line;
5261
- }).join("\n");
5262
- }
5263
5365
  export {
5264
5366
  AutocompleteEngine,
5265
5367
  DARK_COLORS,
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export type { AutocompleteOptions } from './autocomplete/AutocompleteEngine';
10
10
  export { extractValues } from './utils/extractValues';
11
11
  export type { ExtractedValue, ExtractedValueKind } from './utils/extractValues';
12
12
  export { formatQuery } from './utils/formatQuery';
13
+ export type { FormatQueryOptions } from './utils/formatQuery';
13
14
  export { DEFAULT_COLORS, DARK_COLORS, DEFAULT_STYLES, DARK_STYLES } from './constants';
14
15
  export type { ElasticInputProps, ElasticInputAPI, FieldConfig, FieldsSource, FieldType, SavedSearch, HistoryEntry, SuggestionItem, ColorConfig, StyleConfig, ValidateValueContext, ValidationResult, ValidateReturn, TabContext, TabActionResult, DropdownConfig, DropdownOpenContext, DropdownOpenProp, FeaturesConfig, ClassNamesConfig, } from './types';
15
16
  export type { Token, TokenType } from './lexer/tokens';
@@ -24,6 +24,8 @@ export interface BooleanExprNode {
24
24
  operator: 'AND' | 'OR';
25
25
  left: ASTNode;
26
26
  right: ASTNode;
27
+ /** True when the operator was inferred from whitespace (no explicit AND/OR token). */
28
+ implicit?: boolean;
27
29
  start: number;
28
30
  end: number;
29
31
  }
package/dist/types.d.ts CHANGED
@@ -269,6 +269,17 @@ export interface DropdownConfig {
269
269
  * empty partial. When false, the first item is only pre-selected after the user
270
270
  * starts typing a partial match. @default false */
271
271
  autoSelect?: boolean;
272
+ /** When true, Home/End keys navigate to the first/last dropdown item while the
273
+ * dropdown is open and an item is already selected. When no item is selected
274
+ * (index = -1), the keys pass through for normal text cursor movement. @default false */
275
+ homeEndKeys?: boolean;
276
+ /** Called when the engine returns zero suggestions. Return a React element to display
277
+ * in the dropdown (e.g. "No results for …"), or null/undefined to hide the dropdown.
278
+ * Not called during async loading (the spinner handles that). */
279
+ renderNoResults?: (context: {
280
+ cursorContext: CursorContext;
281
+ partial: string;
282
+ }) => React.ReactNode | null | undefined;
272
283
  }
273
284
  /**
274
285
  * Feature toggles for optional editing behaviors. All default to false except `multiline`.
@@ -282,6 +293,8 @@ export interface FeaturesConfig {
282
293
  expandSelection?: boolean;
283
294
  /** Pressing `*` with a single value token selected wraps it in wildcards. @default false */
284
295
  wildcardWrap?: boolean;
296
+ /** Enable Alt+Shift+F to pretty-print the query in-place using `formatQuery`. @default false */
297
+ formatQuery?: boolean;
285
298
  /** Enable `#name` saved-search syntax and autocomplete. When false, `#` is a regular character. @default false */
286
299
  savedSearches?: boolean;
287
300
  /** Enable `!query` history-search syntax and autocomplete. When false, `!` is a regular character. @default false */
@@ -1,7 +1,18 @@
1
1
  import { ASTNode } from '../parser/ast';
2
2
 
3
+ /** Options for `formatQuery` pretty-printing. */
4
+ export interface FormatQueryOptions {
5
+ /** Max length before a line is broken into multiple lines. @default 60 */
6
+ maxLineLength?: number;
7
+ /** Indent string for each nesting level. @default ' ' (2 spaces) */
8
+ indent?: string;
9
+ /** When set, replaces implicit AND (whitespace between terms) with this operator
10
+ * string in the output. By default, implicit AND is preserved as whitespace.
11
+ * @example 'AND' — turns `status:active name:john` into `status:active AND name:john` */
12
+ whitespaceOperator?: string;
13
+ }
3
14
  /**
4
15
  * Pretty-print an Elasticsearch query string.
5
16
  * Accepts a raw query string or a pre-parsed AST node.
6
17
  */
7
- export declare function formatQuery(input: string | ASTNode): string;
18
+ export declare function formatQuery(input: string | ASTNode, options?: FormatQueryOptions): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elastic-input",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Syntax-aware smart autocomplete input for Elastic query syntax",
5
5
  "license": "MIT",
6
6
  "repository": {