& {
+ min-width: min(100%, 300px);
+ }
+
+ textarea {
+ border: none !important;
+ background: transparent !important;
+ word-break: break-all;
+ }
+
+ textarea::placeholder {
+ transform: translateY(1px);
+ }
+}
+
/* Dropdown content */
.aboveSuggestions {
display: flex;
diff --git a/packages/app/src/components/SearchInput/AutocompleteInput.tsx b/packages/app/src/components/SearchInput/AutocompleteInput.tsx
index 0669f30a11..7a8de086be 100644
--- a/packages/app/src/components/SearchInput/AutocompleteInput.tsx
+++ b/packages/app/src/components/SearchInput/AutocompleteInput.tsx
@@ -28,6 +28,8 @@ export default function AutocompleteInput({
language,
onSubmit,
queryHistoryType,
+ filterChips,
+ onRemoveLastChip,
'data-testid': dataTestId,
}: {
inputRef: React.RefObject
;
@@ -47,6 +49,9 @@ export default function AutocompleteInput({
onLanguageChange?: (language: 'sql' | 'lucene') => void;
language?: 'sql' | 'lucene';
queryHistoryType?: string;
+ filterChips?: React.ReactNode;
+ /** Returns true if a chip was actually removed so the host can consume the keystroke. */
+ onRemoveLastChip?: () => boolean;
'data-testid'?: string;
}) {
const suggestionsLimit = 10;
@@ -115,34 +120,37 @@ export default function AutocompleteInput({
onSubmit?.(); // search
};
- const onAcceptSuggestion = (suggestion: string) => {
- setSelectedAutocompleteIndex(-1);
+ const onAcceptSuggestion = useCallback(
+ (suggestion: string) => {
+ setSelectedAutocompleteIndex(-1);
- if (value == null || !tokenInfo) {
- onChange(suggestion);
- inputRef.current?.focus();
- return;
- }
+ if (value == null || !tokenInfo) {
+ onChange(suggestion);
+ inputRef.current?.focus();
+ return;
+ }
- // Replace the token at cursor with the suggestion
- const tokens = [...tokenInfo.tokens];
- tokens[tokenInfo.index] = suggestion;
- const newValue = tokens.join(' ');
+ // Replace the token at cursor with the suggestion
+ const tokens = [...tokenInfo.tokens];
+ tokens[tokenInfo.index] = suggestion;
+ const newValue = tokens.join(' ');
- // Place cursor right after the inserted suggestion
- let newCursorPos = 0;
- for (let i = 0; i <= tokenInfo.index; i++) {
- newCursorPos += tokens[i].length;
- if (i < tokenInfo.index) newCursorPos++; // space
- }
+ // Place cursor right after the inserted suggestion
+ let newCursorPos = 0;
+ for (let i = 0; i <= tokenInfo.index; i++) {
+ newCursorPos += tokens[i].length;
+ if (i < tokenInfo.index) newCursorPos++; // space
+ }
- onChange(newValue);
+ onChange(newValue);
- requestAnimationFrame(() => {
- inputRef.current?.setSelectionRange(newCursorPos, newCursorPos);
- inputRef.current?.focus();
- });
- };
+ requestAnimationFrame(() => {
+ inputRef.current?.setSelectionRange(newCursorPos, newCursorPos);
+ inputRef.current?.focus();
+ });
+ },
+ [value, tokenInfo, onChange, inputRef],
+ );
const ref = useRef(null);
useLayoutEffect(() => {
if (ref.current) {
@@ -156,6 +164,108 @@ export default function AutocompleteInput({
// Height including the 2px border from .textarea (1px top + 1px bottom)
const baseHeight = size === 'xs' ? 30 : size === 'lg' ? 44 : 38;
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ // Ignore keystrokes that belong to an in-progress IME composition
+ // (CJK input). At this point the textarea value is still empty while
+ // uncommitted glyphs live in the composition layer, so Backspace
+ // would otherwise eat a chip instead of editing the composition.
+ if (e.nativeEvent.isComposing || e.keyCode === 229) {
+ return;
+ }
+
+ if (e.key === 'Escape' && e.target instanceof HTMLTextAreaElement) {
+ e.preventDefault();
+ setIsInputDropdownOpen(false);
+ e.target.blur();
+ return;
+ }
+
+ // Backspace at cursor position 0 removes last chip (only if a chip
+ // was actually present — otherwise let backspace behave normally).
+ if (
+ e.key === 'Backspace' &&
+ e.target instanceof HTMLTextAreaElement &&
+ e.target.selectionStart === 0 &&
+ e.target.selectionEnd === 0 &&
+ onRemoveLastChip
+ ) {
+ const removed = onRemoveLastChip();
+ if (removed) {
+ e.preventDefault();
+ // Removing a chip changes what the user is targeting, so a
+ // stale arrow-key suggestion selection would cause the next
+ // Enter to insert an unintended token. Reset and close the
+ // dropdown to mirror the Escape branch.
+ setSelectedAutocompleteIndex(-1);
+ setIsInputDropdownOpen(false);
+ }
+ return;
+ }
+
+ if (!(e.target instanceof HTMLTextAreaElement)) return;
+
+ const hasSelectableSuggestion =
+ suggestedProperties.length > 0 &&
+ selectedAutocompleteIndex >= 0 &&
+ selectedAutocompleteIndex < suggestedProperties.length;
+
+ if (e.key === 'Tab' && hasSelectableSuggestion) {
+ e.preventDefault();
+ onAcceptSuggestion(
+ suggestedProperties[selectedAutocompleteIndex].value,
+ );
+ return;
+ }
+
+ if (e.key === 'Enter') {
+ if (hasSelectableSuggestion) {
+ e.preventDefault();
+ onAcceptSuggestion(
+ suggestedProperties[selectedAutocompleteIndex].value,
+ );
+ } else if (!e.shiftKey) {
+ // Allow shift+enter to still create new lines
+ e.preventDefault();
+ if (queryHistoryType && value) {
+ setQueryHistory(value);
+ }
+ onSubmit?.();
+ }
+ return;
+ }
+
+ if (e.key === 'ArrowDown' && suggestedProperties.length > 0) {
+ e.preventDefault();
+ setSelectedAutocompleteIndex(
+ Math.min(
+ selectedAutocompleteIndex + 1,
+ suggestedProperties.length - 1,
+ suggestionsLimit - 1,
+ ),
+ );
+ return;
+ }
+
+ if (e.key === 'ArrowUp' && suggestedProperties.length > 0) {
+ e.preventDefault();
+ setSelectedAutocompleteIndex(
+ Math.max(selectedAutocompleteIndex - 1, 0),
+ );
+ }
+ },
+ [
+ onRemoveLastChip,
+ suggestedProperties,
+ selectedAutocompleteIndex,
+ onAcceptSuggestion,
+ queryHistoryType,
+ value,
+ setQueryHistory,
+ onSubmit,
+ ],
+ );
+
return (
{aboveSuggestions != null && (
diff --git a/packages/app/src/components/SearchInput/InlineFilterChips.module.scss b/packages/app/src/components/SearchInput/InlineFilterChips.module.scss
new file mode 100644
index 0000000000..38f201f7b6
--- /dev/null
+++ b/packages/app/src/components/SearchInput/InlineFilterChips.module.scss
@@ -0,0 +1,68 @@
+.chipsGroup {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ min-width: 0;
+ max-width: 100%;
+ max-height: 46px;
+ overflow: hidden;
+}
+
+/* When the parent input is focused/expanded, show all chips */
+/* stylelint-disable-next-line selector-pseudo-class-no-unknown */
+:global([data-expanded='true']) .chipsGroup {
+ max-height: none;
+}
+
+.chip,
+.chipExcluded {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 1px 4px;
+ border-radius: 3px;
+ font-size: 11px;
+ line-height: 16px;
+ cursor: default;
+ white-space: nowrap;
+ max-width: 200px;
+ overflow: hidden;
+ flex-shrink: 0;
+ border: 1px solid var(--color-border-emphasis);
+ background: transparent;
+}
+
+.chipExcluded {
+ /* `--mantine-color-red-light` is a near-pink background tint that
+ barely reads as red against the input's chrome in light mode. The
+ `--color-text-danger` token swings between red-3 (dark) and red-8
+ (light) so the border stays clearly red in both themes. */
+ border-color: var(--color-text-danger);
+}
+
+.chipField {
+ font-size: 10px;
+ flex-shrink: 0;
+ max-width: 80px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--color-text-secondary);
+}
+
+.chipOperator {
+ font-size: 10px;
+ flex-shrink: 0;
+ color: var(--color-text-muted);
+}
+
+.chipValue {
+ font-size: 10px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--color-text);
+}
+
+.chipClose {
+ flex-shrink: 0;
+ margin-left: 1px;
+}
diff --git a/packages/app/src/components/SearchInput/InlineFilterChips.tsx b/packages/app/src/components/SearchInput/InlineFilterChips.tsx
new file mode 100644
index 0000000000..407e76e6af
--- /dev/null
+++ b/packages/app/src/components/SearchInput/InlineFilterChips.tsx
@@ -0,0 +1,95 @@
+import { memo, useCallback } from 'react';
+import { ActionIcon, Tooltip } from '@mantine/core';
+import { IconX } from '@tabler/icons-react';
+
+import type { FilterStateHook } from '@/searchFilters';
+
+import { type PillItem, removePill } from '../filterPillUtils';
+
+import styles from './InlineFilterChips.module.scss';
+
+type InlineFilterChipsProps = {
+ pills: PillItem[];
+ setFilterValue: FilterStateHook['setFilterValue'];
+ clearFilter: FilterStateHook['clearFilter'];
+};
+
+function InlineFilterChip({
+ pill,
+ onRemove,
+}: {
+ pill: PillItem;
+ onRemove: () => void;
+}) {
+ const isExcluded = pill.type === 'excluded';
+ const operator = isExcluded ? '!=' : pill.type === 'range' ? ':' : '=';
+ const ariaLabel = `Filter ${pill.field} ${operator} ${pill.value}`;
+
+ return (
+
+ e.preventDefault()}
+ data-testid="filter-chip"
+ data-field={pill.field}
+ data-type={pill.type}
+ data-value={pill.value}
+ aria-label={ariaLabel}
+ >
+
+ {pill.field}
+
+
+ {operator}
+
+
+ {pill.value}
+
+ e.preventDefault()}
+ className={styles.chipClose}
+ data-testid="filter-chip-remove"
+ aria-label={`Remove ${ariaLabel}`}
+ >
+
+
+
+
+ );
+}
+
+export default memo(function InlineFilterChips({
+ pills,
+ setFilterValue,
+ clearFilter,
+}: InlineFilterChipsProps) {
+ const handleRemove = useCallback(
+ (pill: PillItem) => {
+ removePill(pill, setFilterValue, clearFilter);
+ },
+ [setFilterValue, clearFilter],
+ );
+
+ if (pills.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {pills.map(pill => (
+ handleRemove(pill)}
+ />
+ ))}
+
+ );
+});
diff --git a/packages/app/src/components/SearchInput/SearchInputV2.tsx b/packages/app/src/components/SearchInput/SearchInputV2.tsx
index 904b281f32..de288a4593 100644
--- a/packages/app/src/components/SearchInput/SearchInputV2.tsx
+++ b/packages/app/src/components/SearchInput/SearchInputV2.tsx
@@ -46,6 +46,8 @@ export default function SearchInputV2({
queryHistoryType,
dateRange,
sourceId,
+ filterChips,
+ onRemoveLastChip,
'data-testid': dataTestId,
...props
}: {
@@ -60,6 +62,9 @@ export default function SearchInputV2({
queryHistoryType?: string;
dateRange?: [Date, Date];
sourceId?: string;
+ filterChips?: React.ReactNode;
+ /** Returns true if a chip was actually removed so the host can consume the keystroke. */
+ onRemoveLastChip?: () => boolean;
'data-testid'?: string;
} & UseControllerProps &
TableConnectionChoice) {
@@ -129,6 +134,8 @@ export default function SearchInputV2({
onLanguageChange={onLanguageChange}
onSubmit={onSubmit}
queryHistoryType={queryHistoryType}
+ filterChips={filterChips}
+ onRemoveLastChip={onRemoveLastChip}
data-testid={dataTestId}
aboveSuggestions={
<>
diff --git a/packages/app/src/components/SearchInput/SearchWhereInput.module.scss b/packages/app/src/components/SearchInput/SearchWhereInput.module.scss
index 21674397ad..f62c4246e4 100644
--- a/packages/app/src/components/SearchInput/SearchWhereInput.module.scss
+++ b/packages/app/src/components/SearchInput/SearchWhereInput.module.scss
@@ -23,15 +23,11 @@
border-bottom: 2px solid var(--color-bg-brand) !important;
}
- /* When AutocompleteInput (Lucene) is expanded, round the input wrapper bottom-left corner */
+ /* When AutocompleteInput (Lucene) inputContainer is expanded (focused),
+ round the bottom-left corner */
/* stylelint-disable-next-line selector-pseudo-class-no-unknown */
- :global([data-expanded='true']) :global(.mantine-InputWrapper-root) {
+ :global([data-expanded='true']) :global([data-autocomplete-container]) {
border-bottom-left-radius: var(--mantine-radius-default);
- border-bottom: 2px solid var(--color-bg-brand);
-
- textarea {
- min-height: 38px !important;
- }
}
/* target only the SQLInlineEditor Paper (not the AutocompleteInput) */
@@ -41,15 +37,23 @@
border-bottom-left-radius: 0;
}
- /* target only the AutocompleteInput Paper (not the SQLInlineEditor) */
+ /* target the AutocompleteInput container (Lucene mode) */
+ /* stylelint-disable-next-line selector-pseudo-class-no-unknown */
+ :global([data-autocomplete-container]) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+
+ /* Mantine's