Skip to content

Commit cc7eab9

Browse files
authored
feat: updated highlight status when using the new filter bar (supabase#42623)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? The experience is now based on the keyboard - Selecting a filter can be done by pressing left or backspace on the keyboard - Pressing enter allows you to select and edit that filter - Pressing backspace again on a filters value deletes it - Left or right arrows does selection, enter to edit ## Demo ![filters_new_keyboard](https://github.com/user-attachments/assets/3bc6f348-5a03-448f-93b8-a48ac3068c52) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Filter conditions now display visual highlighting with a distinct ring outline when navigated via keyboard * Enhanced keyboard navigation behavior for more intuitive filter management * Filter condition highlights can be cleared using the Escape key * Improved navigation flow when moving through filter groups and conditions <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4decdd3 commit cc7eab9

10 files changed

Lines changed: 242 additions & 162 deletions

File tree

packages/ui-patterns/src/FilterBar/FilterBarContext.tsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22

33
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react'
44

5-
import { ActiveInput, useFilterBarState, useOptionsCache } from './hooks'
5+
import { useFilterBarState, useOptionsCache } from './hooks'
66
import { MenuItem } from './menuItems'
7-
import { FilterBarAction, FilterGroup, FilterOptionObject, FilterProperty } from './types'
7+
import {
8+
ActiveInputState,
9+
FilterBarAction,
10+
FilterGroup,
11+
FilterOptionObject,
12+
FilterProperty,
13+
} from './types'
814
import { useCommandHandling } from './useCommandHandling'
915
import { useKeyboardNavigation } from './useKeyboardNavigation'
1016
import {
@@ -20,15 +26,16 @@ export type FilterBarContextValue = {
2026
// Core state
2127
filters: FilterGroup
2228
filterProperties: FilterProperty[]
23-
activeInput: ActiveInput
29+
activeInput: ActiveInputState
2430
freeformText: string
2531
isLoading: boolean
2632
error: string | null
33+
highlightedConditionPath: number[] | null
2734

2835
// Handlers
2936
onFilterChange: (filters: FilterGroup) => void
3037
onFreeformTextChange: (text: string) => void
31-
setActiveInput: (input: ActiveInput) => void
38+
setActiveInput: (input: ActiveInputState) => void
3239
handleInputChange: (path: number[], value: string) => void
3340
handleOperatorChange: (path: number[], value: string) => void
3441
handleRemoveCondition: (path: number[]) => void
@@ -110,6 +117,8 @@ export function FilterBarRoot({
110117
setActiveInput,
111118
newPathRef,
112119
setIsCommandMenuVisible,
120+
highlightedConditionPath,
121+
setHighlightedConditionPath,
113122
} = useFilterBarState()
114123

115124
const { loadingOptions, propertyOptionsCache, loadPropertyOptions, optionsError } =
@@ -163,6 +172,8 @@ export function FilterBarRoot({
163172
setActiveInput,
164173
activeFilters: filters,
165174
onFilterChange,
175+
highlightedConditionPath,
176+
setHighlightedConditionPath,
166177
})
167178

168179
const handleInputFocus = useCallback(
@@ -229,14 +240,20 @@ export function FilterBarRoot({
229240
}
230241
setIsCommandMenuVisible(false)
231242
setActiveInput(null)
243+
// Clear highlight when clicking outside
244+
setHighlightedConditionPath(null)
232245
}, 0)
233-
}, [setIsCommandMenuVisible, setActiveInput, hideTimeoutRef])
246+
}, [setIsCommandMenuVisible, setActiveInput, hideTimeoutRef, setHighlightedConditionPath])
234247

235248
const handleGroupFreeformChange = useCallback(
236249
(_path: number[], value: string) => {
250+
// Clear highlight when user types
251+
if (highlightedConditionPath) {
252+
setHighlightedConditionPath(null)
253+
}
237254
onFreeformTextChange(value)
238255
},
239-
[onFreeformTextChange]
256+
[onFreeformTextChange, highlightedConditionPath, setHighlightedConditionPath]
240257
)
241258

242259
const handleLabelClick = useCallback(
@@ -282,6 +299,7 @@ export function FilterBarRoot({
282299
freeformText,
283300
isLoading: loading,
284301
error,
302+
highlightedConditionPath,
285303

286304
// Handlers
287305
onFilterChange,

packages/ui-patterns/src/FilterBar/FilterCondition.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ export type FilterConditionProps = {
2222
path: number[]
2323
isActive: boolean
2424
isOperatorActive: boolean
25+
isHighlighted: boolean
2526
}
2627

2728
export function FilterCondition({
2829
condition,
2930
path,
3031
isActive,
3132
isOperatorActive,
33+
isHighlighted,
3234
}: FilterConditionProps) {
3335
const {
3436
filters: rootFilters,
@@ -210,7 +212,8 @@ export function FilterCondition({
210212
ref={wrapperRef}
211213
className={cn(
212214
'flex items-stretch px-0 bg-muted group shrink-0',
213-
variant === 'pill' ? 'rounded border' : 'border-r'
215+
variant === 'pill' ? 'rounded border' : 'border-r',
216+
isHighlighted && 'ring-2 ring-primary'
214217
)}
215218
>
216219
<span

packages/ui-patterns/src/FilterBar/FilterGroup.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
3131
supportsOperators,
3232
actions,
3333
variant,
34+
highlightedConditionPath,
3435
handleInputBlur,
3536
handleGroupFreeformFocus,
3637
handleGroupFreeformChange,
@@ -83,6 +84,11 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
8384
return activeInput.type === 'operator' && pathsEqual(conditionPath, activeInput.path)
8485
}
8586

87+
const isConditionHighlighted = (conditionPath: number[]) => {
88+
if (!highlightedConditionPath) return false
89+
return pathsEqual(conditionPath, highlightedConditionPath)
90+
}
91+
8692
const items = useMemo(
8793
() =>
8894
buildPropertyItems({
@@ -106,7 +112,8 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
106112
(index) => {
107113
if (items[index]) handleSelectMenuItem(items[index])
108114
},
109-
handleKeyDown
115+
handleKeyDown,
116+
{ skipEnterWhenFilterHighlighted: highlightedConditionPath !== null }
110117
)
111118

112119
useEffect(() => {
@@ -156,12 +163,15 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
156163
path={currentPath}
157164
isActive={isConditionActive(currentPath)}
158165
isOperatorActive={isOperatorActive(currentPath)}
166+
isHighlighted={isConditionHighlighted(currentPath)}
159167
/>
160168
)}
161169
</React.Fragment>
162170
)
163171
})}
164-
<Popover_Shadcn_ open={isActive && !isLoading && items.length > 0}>
172+
<Popover_Shadcn_
173+
open={isActive && !isLoading && items.length > 0 && !highlightedConditionPath}
174+
>
165175
<PopoverAnchor_Shadcn_ asChild>
166176
{isRootGroup ? (
167177
<Input_Shadcn_

packages/ui-patterns/src/FilterBar/hooks.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,29 @@
22

33
import { useCallback, useEffect, useRef, useState } from 'react'
44

5-
import { AsyncOptionsFunction, FilterOptionObject, FilterProperty } from './types'
5+
import {
6+
ActiveInputState,
7+
AsyncOptionsFunction,
8+
ConditionPath,
9+
FilterOptionObject,
10+
FilterProperty,
11+
} from './types'
612
import { isAsyncOptionsFunction } from './utils'
713

8-
export type ActiveInput =
9-
| { type: 'value'; path: number[] }
10-
| { type: 'operator'; path: number[] }
11-
| { type: 'group'; path: number[] }
12-
| null
14+
export type HighlightNavigationOptions = {
15+
skipEnterWhenFilterHighlighted?: boolean
16+
}
1317

1418
export function useFilterBarState() {
1519
const [isLoading, setIsLoading] = useState(false)
1620
const [error, setError] = useState<string | null>(null)
1721
const [isCommandMenuVisible, setIsCommandMenuVisible] = useState(false)
1822
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null)
19-
const [activeInput, setActiveInput] = useState<ActiveInput>(null)
20-
const newPathRef = useRef<number[]>([])
23+
const [activeInput, setActiveInput] = useState<ActiveInputState>(null)
24+
const newPathRef = useRef<ConditionPath>([])
25+
const [highlightedConditionPath, setHighlightedConditionPath] = useState<ConditionPath | null>(
26+
null
27+
)
2128

2229
return {
2330
isLoading,
@@ -30,6 +37,8 @@ export function useFilterBarState() {
3037
activeInput,
3138
setActiveInput,
3239
newPathRef,
40+
highlightedConditionPath,
41+
setHighlightedConditionPath,
3342
}
3443
}
3544

@@ -122,7 +131,8 @@ export function useDeferredBlur(wrapperRef: React.RefObject<HTMLElement>, onBlur
122131
export function useHighlightNavigation(
123132
itemsLength: number,
124133
onEnter: (index: number) => void,
125-
fallbackKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
134+
fallbackKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void,
135+
options?: HighlightNavigationOptions
126136
) {
127137
const [highlightedIndex, setHighlightedIndex] = useState(0)
128138

@@ -145,7 +155,11 @@ export function useHighlightNavigation(
145155
return
146156
}
147157
if (e.key === 'Enter' || e.key === 'Tab') {
148-
// Only select dropdown item if there are items available
158+
// Edge case: when a filter is highlighted, skip dropdown selection and let fallback handle it
159+
if (options?.skipEnterWhenFilterHighlighted) {
160+
if (fallbackKeyDown) fallbackKeyDown(e)
161+
return
162+
}
149163
if (itemsLength > 0) {
150164
e.preventDefault()
151165
onEnter(highlightedIndex)
@@ -160,7 +174,13 @@ export function useHighlightNavigation(
160174
}
161175
if (fallbackKeyDown) fallbackKeyDown(e)
162176
},
163-
[itemsLength, highlightedIndex, onEnter, fallbackKeyDown]
177+
[
178+
itemsLength,
179+
highlightedIndex,
180+
onEnter,
181+
fallbackKeyDown,
182+
options?.skipEnterWhenFilterHighlighted,
183+
]
164184
)
165185

166186
const reset = useCallback(() => setHighlightedIndex(0), [])

packages/ui-patterns/src/FilterBar/menuItems.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { ActiveInput } from './hooks'
2-
import { FilterBarAction, FilterGroup, FilterProperty } from './types'
1+
import { ActiveInputState, FilterBarAction, FilterGroup, FilterProperty } from './types'
32
import {
43
findConditionByPath,
54
isCustomOptionObject,
@@ -19,7 +18,7 @@ export type MenuItem = {
1918
}
2019

2120
export function buildOperatorItems(
22-
activeInput: Extract<ActiveInput, { type: 'operator' }> | null,
21+
activeInput: Extract<ActiveInputState, { type: 'operator' }> | null,
2322
activeFilters: FilterGroup,
2423
filterProperties: FilterProperty[],
2524
hasTypedSinceFocus: boolean = true
@@ -89,7 +88,7 @@ export function buildPropertyItems(params: {
8988
}
9089

9190
export function buildValueItems(
92-
activeInput: Extract<ActiveInput, { type: 'value' }> | null,
91+
activeInput: Extract<ActiveInputState, { type: 'value' }> | null,
9392
activeFilters: FilterGroup,
9493
filterProperties: FilterProperty[],
9594
propertyOptionsCache: Record<string, { options: any[]; searchValue: string }>,

packages/ui-patterns/src/FilterBar/types.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,15 @@ export function isGroup(condition: FilterCondition | FilterGroup): condition is
5151
return 'logicalOperator' in condition
5252
}
5353

54+
export type ConditionPath = number[]
55+
5456
export type FilterBarAction = {
5557
value: string
5658
label: string
5759
icon?: React.ReactNode
5860
onSelect: (
5961
inputValue: string,
60-
context: { path: number[]; activeFilters: FilterGroup }
62+
context: { path: ConditionPath; activeFilters: FilterGroup }
6163
) => void | Promise<void>
6264
}
6365

@@ -71,5 +73,24 @@ export type SerializableFilterProperty = Pick<
7173
export type AIFilterRequestPayload = {
7274
prompt: string
7375
filterProperties: SerializableFilterProperty[]
74-
currentPath: number[]
76+
currentPath: ConditionPath
77+
}
78+
79+
export type NavigationDirection = 'prev' | 'next'
80+
81+
export type HighlightNavigationResult = ConditionPath | 'clear' | null
82+
83+
export type ActiveInputState =
84+
| { type: 'value'; path: ConditionPath }
85+
| { type: 'operator'; path: ConditionPath }
86+
| { type: 'group'; path: ConditionPath }
87+
| null
88+
89+
export type KeyboardNavigationConfig = {
90+
activeInput: ActiveInputState
91+
setActiveInput: (input: ActiveInputState) => void
92+
activeFilters: FilterGroup
93+
onFilterChange: (filters: FilterGroup) => void
94+
highlightedConditionPath: ConditionPath | null
95+
setHighlightedConditionPath: (path: ConditionPath | null) => void
7596
}

packages/ui-patterns/src/FilterBar/useAIFilter.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useCallback } from 'react'
22

3-
import { ActiveInput } from './hooks'
4-
import { FilterGroup, FilterProperty, isGroup } from './types'
3+
import { ActiveInputState, FilterGroup, FilterProperty, isGroup } from './types'
54
import { updateGroupAtPath } from './utils'
65

76
export function useAIFilter({
@@ -16,7 +15,7 @@ export function useAIFilter({
1615
setError,
1716
setIsCommandMenuVisible,
1817
}: {
19-
activeInput: ActiveInput
18+
activeInput: ActiveInputState
2019
aiApiUrl?: string
2120
freeformText: string
2221
filterProperties: FilterProperty[]

packages/ui-patterns/src/FilterBar/useCommandHandling.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { useCallback } from 'react'
22

3-
import { ActiveInput } from './hooks'
43
import { MenuItem } from './menuItems'
5-
import { FilterGroup, FilterProperty } from './types'
4+
import { ActiveInputState, FilterGroup, FilterProperty } from './types'
65
import { addFilterToGroup, addGroupToGroup, findGroupByPath, isCustomOptionObject } from './utils'
76

87
export function useCommandHandling({
@@ -18,8 +17,8 @@ export function useCommandHandling({
1817
newPathRef,
1918
setIsCommandMenuVisible,
2019
}: {
21-
activeInput: ActiveInput
22-
setActiveInput: (input: ActiveInput) => void
20+
activeInput: ActiveInputState
21+
setActiveInput: (input: ActiveInputState) => void
2322
activeFilters: FilterGroup
2423
onFilterChange: (filters: FilterGroup) => void
2524
filterProperties: FilterProperty[]

packages/ui-patterns/src/FilterBar/useCommandMenu.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { Sparkles } from 'lucide-react'
22
import { useMemo } from 'react'
33
import * as React from 'react'
44

5-
import { ActiveInput } from './hooks'
6-
import { FilterGroup, FilterProperty } from './types'
5+
import { ActiveInputState, FilterGroup, FilterProperty } from './types'
76
import {
87
findConditionByPath,
98
isCustomOptionObject,
@@ -31,7 +30,7 @@ export function useCommandMenu({
3130
aiApiUrl,
3231
supportsOperators,
3332
}: {
34-
activeInput: ActiveInput
33+
activeInput: ActiveInputState
3534
freeformText: string
3635
activeFilters: FilterGroup
3736
filterProperties: FilterProperty[]
@@ -94,7 +93,7 @@ export function useCommandMenu({
9493
}
9594

9695
function getOperatorItems(
97-
activeInput: Extract<ActiveInput, { type: 'operator' }>,
96+
activeInput: Extract<ActiveInputState, { type: 'operator' }>,
9897
activeFilters: FilterGroup,
9998
filterProperties: FilterProperty[]
10099
): CommandItem[] {
@@ -117,7 +116,7 @@ function getOperatorItems(
117116
}
118117

119118
function getInputValue(
120-
activeInput: ActiveInput,
119+
activeInput: ActiveInputState,
121120
freeformText: string,
122121
activeFilters: FilterGroup
123122
): string {
@@ -138,7 +137,7 @@ function getPropertyItems(filterProperties: FilterProperty[], inputValue: string
138137
}
139138

140139
function getValueItems(
141-
activeInput: Extract<ActiveInput, { type: 'value' }>,
140+
activeInput: Extract<ActiveInputState, { type: 'value' }>,
142141
activeFilters: FilterGroup,
143142
filterProperties: FilterProperty[],
144143
propertyOptionsCache: Record<string, { options: any[]; searchValue: string }>,

0 commit comments

Comments
 (0)