Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,6 @@ export interface QueryBarContextValue {
onCustomAttributeCommit: (customText: string) => void;
/** Ref to the currently open menu content element */
menuRef: RefObject<HTMLDivElement | null>;
/** Close autocomplete menu (used by connector chip to enforce single-dropdown constraint) */
closeAutocompleteMenu: () => void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface AutocompleteForContext {
handleCustomValueCommit: (customText: string) => void;
handleCustomAttributeCommit: (customText: string) => void;
menuRef: RefObject<HTMLDivElement | null>;
closeAutocompleteMenu: () => void;
}

interface UseQueryBarContextValueOptions {
Expand Down Expand Up @@ -77,6 +78,7 @@ export const useQueryBarContextValue = ({
onCustomValueCommit: autocomplete.handleCustomValueCommit,
onCustomAttributeCommit: autocomplete.handleCustomAttributeCommit,
menuRef: autocomplete.menuRef,
closeAutocompleteMenu: autocomplete.closeAutocompleteMenu,
}),
[
chips,
Expand All @@ -101,6 +103,7 @@ export const useQueryBarContextValue = ({
autocomplete.handleCustomValueCommit,
autocomplete.handleCustomAttributeCommit,
autocomplete.menuRef,
autocomplete.closeAutocompleteMenu,
buildingChipRef,
inputRef,
placeholder,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FC } from 'react';
import { type FC, useCallback, useState } from 'react';
import { CirclePlus } from '../../../../icons/CirclePlus';
import { CircleSlash } from '../../../../icons/CircleSlash';
import { cn } from '../../../../utils/cn';
Expand All @@ -13,6 +13,7 @@ import {
import { DropdownMenuTrigger } from '../../../DropdownMenu/DropdownMenuTrigger';
import { Kbd, KbdGroup } from '../../../Kbd';
import { VARIANT_LABELS } from '../../lib/constants';
import { useQueryBarContext } from '../../QueryBarContext';
import { chipVariants, segmentContainer } from '../QueryBarChip/classes';
import { connectorTextVariants } from './classes';

Expand All @@ -31,10 +32,25 @@ export const QueryBarConnectorChip: FC<QueryBarConnectorChipProps> = ({
onChange,
className,
}) => {
const { menuOpen, closeAutocompleteMenu } = useQueryBarContext();
const [open, setOpen] = useState(false);

const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) closeAutocompleteMenu();
setOpen(nextOpen);
},
[closeAutocompleteMenu],
);

const label = VARIANT_LABELS[variant];

return (
<DropdownMenu positioning={{ placement: 'bottom', gutter: 4 }}>
<DropdownMenu
positioning={{ placement: 'bottom', gutter: 4 }}
open={open && !menuOpen}
onOpenChange={handleOpenChange}
>
<DropdownMenuTrigger asChild>
<button
type='button'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ import type { FC } from 'react';
import { useQueryBarContext } from '../QueryBarContext';
import { queryBarInputVariants } from './classes';

/** Approximate width of a single character in px (text-sm monospace) */
const CHAR_WIDTH_PX = 8;

interface QueryBarFilterInputProps {
hasContent: boolean;
minWidth?: number;
}

export const QueryBarFilterInput: FC<QueryBarFilterInputProps> = ({ hasContent }) => {
export const QueryBarFilterInput: FC<QueryBarFilterInputProps> = ({ hasContent, minWidth = 4 }) => {
const {
inputText,
inputRef,
Expand All @@ -32,7 +36,11 @@ export const QueryBarFilterInput: FC<QueryBarFilterInputProps> = ({ hasContent }
onKeyDown={onInputKeyDown}
onClick={onInputClick}
placeholder={hasContent ? undefined : placeholder}
style={hasContent ? { width: `${Math.max(4, inputText.length * 8)}px` } : undefined}
style={
hasContent
? { width: `${Math.max(minWidth, inputText.length * CHAR_WIDTH_PX)}px` }
: undefined
}
className={queryBarInputVariants({ hasContent })}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { inputVariants } from '../../Input/classes';
import { findChipSplitIndex, isMenuRelated } from '../lib';
import { useQueryBarContext } from '../QueryBarContext';
import { ChipsWithGaps, TrailingGap } from './ChipsWithGaps';
import { queryBarContainerVariants, queryBarInnerVariants } from './classes';
import {
buildingChipWrapperClass,
queryBarContainerVariants,
queryBarInnerVariants,
} from './classes';
import { EditingProvider } from './QueryBarChip/EditingContext';
import { QueryBarChip } from './QueryBarChip/QueryBarChip';
import { QueryBarFilterInput } from './QueryBarFilterInput';
Expand Down Expand Up @@ -122,19 +126,24 @@ export const QueryBarInput: FC<QueryBarInputProps> = ({ className, ...props }) =
>
<ChipsWithGaps chips={chipsBefore} hideTrailingGap={hideTrailingGap} {...chipsGapProps} />

{buildingChipData && (
<div ref={buildingChipRef} className={cn('min-w-0', hasContent && 'ml-8')}>
{buildingChipData ? (
<div
ref={buildingChipRef}
className={cn(buildingChipWrapperClass, hasContent && 'ml-8')}
>
<QueryBarChip
building
attribute={buildingChipData.attribute ?? ''}
operator={buildingChipData.operator}
value={buildingChipData.value}
className='border-none'
/>
<QueryBarFilterInput hasContent />
</div>
) : (
<QueryBarFilterInput hasContent={hasContent} />
)}

<QueryBarFilterInput hasContent={hasContent} />

<ChipsWithGaps chips={chipsAfter} hideLeadingGap={hideLeadingGap} {...chipsGapProps} />

{chipsAfter.length > 0 && <TrailingGap chips={chipsAfter} onGapClick={onGapClick} />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export const queryBarInnerVariants = cva(
},
);

/** Wrapper that visually groups the building chip and the input as one unit */
export const buildingChipWrapperClass =
'flex items-center gap-2 min-w-0 rounded-8 border border-solid border-border-strong-primary bg-badge-badge-bg';

/** Native input element inside the query bar */
export const queryBarInputVariants = cva(
'h-auto border-none bg-transparent p-0 text-sm shadow-none outline-none ring-0',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FC, RefObject } from 'react';
import { isBetweenOperator, isMultiSelectOperator } from '../lib';
import { getFieldValues, isBetweenOperator, isMultiSelectOperator } from '../lib';
import type { ChipSegment } from '../QueryBarInput/QueryBarChip';
import type { FieldMetadata, FilterOperator, MenuState } from '../types';
import { QueryBarDateValueMenu } from './QueryBarDateValueMenu';
Expand Down Expand Up @@ -62,8 +62,11 @@ export const QueryBarMenu: FC<QueryBarMenuProps> = ({ fields, autocomplete }) =>

// Route filter text: use segment inline text when editing, otherwise main input
const fieldFilterText = editingSegment === 'attribute' ? segmentFilterText : inputText;
// Operator is not inline-editable — always show all options
const operatorFilterText = '';
// Operator: filter by typed text when building a new chip (not inline editing)
const operatorFilterText = !editingSegment ? inputText : '';

const selectedFieldValues = selectedField ? getFieldValues(selectedField) : [];

// For multi-select, filter by the text after the last comma —
// but only if that token is NOT already a known selected value (otherwise show all).
const valueFilterText = (() => {
Expand All @@ -72,9 +75,8 @@ export const QueryBarMenu: FC<QueryBarMenuProps> = ({ fields, autocomplete }) =>
const lastToken = segmentFilterText.split(',').pop()?.trim() ?? '';
if (!lastToken) return '';
// If the last token matches a field value label/value, it's a completed selection — don't filter
const fieldValues = selectedField?.values;
if (fieldValues) {
const isKnownValue = fieldValues.some(
if (selectedFieldValues.length > 0) {
const isKnownValue = selectedFieldValues.some(
v =>
v.label.toLowerCase() === lastToken.toLowerCase() ||
String(v.value).toLowerCase() === lastToken.toLowerCase(),
Expand All @@ -101,6 +103,7 @@ export const QueryBarMenu: FC<QueryBarMenuProps> = ({ fields, autocomplete }) =>
{selectedField && (
<QueryBarOperatorMenu
fieldType={selectedField.type}
operators={selectedField.operators}
open={menuState === 'operator'}
onSelect={handleOperatorSelect}
onOpenChange={() => handleMenuClose()}
Expand Down Expand Up @@ -131,22 +134,25 @@ export const QueryBarMenu: FC<QueryBarMenuProps> = ({ fields, autocomplete }) =>
initialValue={editingSingleValue != null ? String(editingSingleValue) : undefined}
/>
) : (
<QueryBarValueMenu
values={selectedField.values || []}
open={menuState === 'value'}
onSelect={handleValueSelect}
onCommit={handleMultiCommit}
onOpenChange={() => handleMenuClose()}
onEscape={handleMenuDiscard}
multiSelect={isMultiSelectOperator(selectedOperator)}
initialValues={editingMultiValues}
highlightValue={editingSingleValue}
positioning={menuPositioning}
onBuildingValueChange={handleBuildingValueChange}
inputRef={inputRef}
menuRef={menuRef}
filterText={valueFilterText}
/>
// Freeform fields (no predefined values) skip the dropdown — user types and presses Enter
selectedFieldValues.length > 0 && (
<QueryBarValueMenu
values={selectedFieldValues}
open={menuState === 'value'}
onSelect={handleValueSelect}
onCommit={handleMultiCommit}
onOpenChange={() => handleMenuClose()}
onEscape={handleMenuDiscard}
multiSelect={isMultiSelectOperator(selectedOperator)}
initialValues={editingMultiValues}
highlightValue={editingSingleValue}
positioning={menuPositioning}
onBuildingValueChange={handleBuildingValueChange}
inputRef={inputRef}
menuRef={menuRef}
filterText={valueFilterText}
/>
)
))}
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,37 @@ import {
} from '../../DropdownMenu';
import { Kbd } from '../../Kbd/Kbd';
import { KbdGroup } from '../../Kbd/KbdGroup';
import { getOperatorLabel, OPERATORS_BY_TYPE } from '../lib';
import { getOperatorLabel, OPERATOR_SYMBOLS, OPERATORS_BY_TYPE } from '../lib';
import type { FieldType, FilterOperator, QueryBarDropdownItem } from '../types';
import { MenuEmptyState } from './MenuEmptyState';
import { useKeyboardNav } from './useKeyboardNav';

/**
* Build operator groups filtered by a custom operators list.
* Preserves grouping from OPERATORS_BY_TYPE but only keeps operators present in the list.
*/
function filterOperatorGroups(
groups: FilterOperator[][],
operators: FilterOperator[],
): FilterOperator[][] {
const allowed = new Set(operators);
return groups.map(group => group.filter(op => allowed.has(op))).filter(group => group.length > 0);
}

/** Field types where operator symbols are hidden (per Figma spec) */
const HIDE_SYMBOLS_FOR: ReadonlySet<FieldType> = new Set(['boolean']);

export interface QueryBarOperatorMenuProps {
/**
* The field type to determine which operators to show
*/
fieldType: FieldType;
/**
* Optional list of operators from field config.
* When provided, only these operators are shown (preserving type-based grouping).
* Falls back to OPERATORS_BY_TYPE[fieldType] when not set.
*/
operators?: FilterOperator[];
/**
* Callback when an operator is selected
*/
Expand Down Expand Up @@ -60,6 +81,7 @@ export interface QueryBarOperatorMenuProps {
*/
export const QueryBarOperatorMenu: FC<QueryBarOperatorMenuProps> = ({
fieldType,
operators,
onSelect,
open = false,
onOpenChange,
Expand All @@ -70,15 +92,24 @@ export const QueryBarOperatorMenu: FC<QueryBarOperatorMenuProps> = ({
menuRef,
className,
}) => {
const operatorGroups = OPERATORS_BY_TYPE[fieldType] || [];
const query = filterText.toLowerCase();

const operatorGroups = useMemo(() => {
const allGroups = OPERATORS_BY_TYPE[fieldType] || [];
return operators ? filterOperatorGroups(allGroups, operators) : allGroups;
}, [fieldType, operators]);

const filteredGroups = useMemo(
() =>
query
? operatorGroups
.map(group =>
group.filter(op => getOperatorLabel(op, fieldType).toLowerCase().includes(query)),
group.filter(
op =>
getOperatorLabel(op, fieldType).toLowerCase().includes(query) ||
OPERATOR_SYMBOLS[op].toLowerCase().includes(query) ||
op.toLowerCase().includes(query),
),
)
.filter(group => group.length > 0)
: operatorGroups,
Expand Down Expand Up @@ -115,7 +146,7 @@ export const QueryBarOperatorMenu: FC<QueryBarOperatorMenuProps> = ({
highlightedValue={highlightedValue}
onHighlightChange={onHighlightChange}
>
<DropdownMenuContent ref={menuRef} className={cn('w-64', className)}>
<DropdownMenuContent ref={menuRef} className={cn('w-256', className)}>
{filteredGroups.length > 0 ? (
filteredGroups.map((group, groupIdx) => (
<Fragment key={`group-${groupIdx}`}>
Expand All @@ -129,6 +160,11 @@ export const QueryBarOperatorMenu: FC<QueryBarOperatorMenuProps> = ({
<DropdownMenuItemText>
{getOperatorLabel(operator, fieldType)}
</DropdownMenuItemText>
{!HIDE_SYMBOLS_FOR.has(fieldType) && (
<span className='ml-auto inline-flex items-center'>
<Kbd className='font-mono'>{OPERATOR_SYMBOLS[operator]}</Kbd>
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
Expand Down
Loading
Loading