mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: updated highlight status when using the new filter bar (#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  <!-- 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 -->
This commit is contained in:
1 parent
4decdd3680
commit
cc7eab9dcf
10 files changed
+246
-166
No files matched your search
@@ -2,9 +2,15 @@
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react'
|
||||
|
||||
import { ActiveInput, useFilterBarState, useOptionsCache } from './hooks'
|
||||
import { useFilterBarState, useOptionsCache } from './hooks'
|
||||
import { MenuItem } from './menuItems'
|
||||
import { FilterBarAction, FilterGroup, FilterOptionObject, FilterProperty } from './types'
|
||||
import {
|
||||
ActiveInputState,
|
||||
FilterBarAction,
|
||||
FilterGroup,
|
||||
FilterOptionObject,
|
||||
FilterProperty,
|
||||
} from './types'
|
||||
import { useCommandHandling } from './useCommandHandling'
|
||||
import { useKeyboardNavigation } from './useKeyboardNavigation'
|
||||
import {
|
||||
@@ -20,15 +26,16 @@ export type FilterBarContextValue = {
|
||||
// Core state
|
||||
filters: FilterGroup
|
||||
filterProperties: FilterProperty[]
|
||||
activeInput: ActiveInput
|
||||
activeInput: ActiveInputState
|
||||
freeformText: string
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
highlightedConditionPath: number[] | null
|
||||
|
||||
// Handlers
|
||||
onFilterChange: (filters: FilterGroup) => void
|
||||
onFreeformTextChange: (text: string) => void
|
||||
setActiveInput: (input: ActiveInput) => void
|
||||
setActiveInput: (input: ActiveInputState) => void
|
||||
handleInputChange: (path: number[], value: string) => void
|
||||
handleOperatorChange: (path: number[], value: string) => void
|
||||
handleRemoveCondition: (path: number[]) => void
|
||||
@@ -110,6 +117,8 @@ export function FilterBarRoot({
|
||||
setActiveInput,
|
||||
newPathRef,
|
||||
setIsCommandMenuVisible,
|
||||
highlightedConditionPath,
|
||||
setHighlightedConditionPath,
|
||||
} = useFilterBarState()
|
||||
|
||||
const { loadingOptions, propertyOptionsCache, loadPropertyOptions, optionsError } =
|
||||
@@ -163,6 +172,8 @@ export function FilterBarRoot({
|
||||
setActiveInput,
|
||||
activeFilters: filters,
|
||||
onFilterChange,
|
||||
highlightedConditionPath,
|
||||
setHighlightedConditionPath,
|
||||
})
|
||||
|
||||
const handleInputFocus = useCallback(
|
||||
@@ -229,14 +240,20 @@ export function FilterBarRoot({
|
||||
}
|
||||
setIsCommandMenuVisible(false)
|
||||
setActiveInput(null)
|
||||
// Clear highlight when clicking outside
|
||||
setHighlightedConditionPath(null)
|
||||
}, 0)
|
||||
}, [setIsCommandMenuVisible, setActiveInput, hideTimeoutRef])
|
||||
}, [setIsCommandMenuVisible, setActiveInput, hideTimeoutRef, setHighlightedConditionPath])
|
||||
|
||||
const handleGroupFreeformChange = useCallback(
|
||||
(_path: number[], value: string) => {
|
||||
// Clear highlight when user types
|
||||
if (highlightedConditionPath) {
|
||||
setHighlightedConditionPath(null)
|
||||
}
|
||||
onFreeformTextChange(value)
|
||||
},
|
||||
[onFreeformTextChange]
|
||||
[onFreeformTextChange, highlightedConditionPath, setHighlightedConditionPath]
|
||||
)
|
||||
|
||||
const handleLabelClick = useCallback(
|
||||
@@ -282,6 +299,7 @@ export function FilterBarRoot({
|
||||
freeformText,
|
||||
isLoading: loading,
|
||||
error,
|
||||
highlightedConditionPath,
|
||||
|
||||
// Handlers
|
||||
onFilterChange,
|
||||
|
||||
@@ -22,6 +22,7 @@ export type FilterConditionProps = {
|
||||
path: number[]
|
||||
isActive: boolean
|
||||
isOperatorActive: boolean
|
||||
isHighlighted: boolean
|
||||
}
|
||||
|
||||
export function FilterCondition({
|
||||
@@ -29,6 +30,7 @@ export function FilterCondition({
|
||||
path,
|
||||
isActive,
|
||||
isOperatorActive,
|
||||
isHighlighted,
|
||||
}: FilterConditionProps) {
|
||||
const {
|
||||
filters: rootFilters,
|
||||
@@ -210,7 +212,8 @@ export function FilterCondition({
|
||||
ref={wrapperRef}
|
||||
className={cn(
|
||||
'flex items-stretch px-0 bg-muted group shrink-0',
|
||||
variant === 'pill' ? 'rounded border' : 'border-r'
|
||||
variant === 'pill' ? 'rounded border' : 'border-r',
|
||||
isHighlighted && 'ring-2 ring-primary'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -31,6 +31,7 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
|
||||
supportsOperators,
|
||||
actions,
|
||||
variant,
|
||||
highlightedConditionPath,
|
||||
handleInputBlur,
|
||||
handleGroupFreeformFocus,
|
||||
handleGroupFreeformChange,
|
||||
@@ -83,6 +84,11 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
|
||||
return activeInput.type === 'operator' && pathsEqual(conditionPath, activeInput.path)
|
||||
}
|
||||
|
||||
const isConditionHighlighted = (conditionPath: number[]) => {
|
||||
if (!highlightedConditionPath) return false
|
||||
return pathsEqual(conditionPath, highlightedConditionPath)
|
||||
}
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
buildPropertyItems({
|
||||
@@ -106,7 +112,8 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
|
||||
(index) => {
|
||||
if (items[index]) handleSelectMenuItem(items[index])
|
||||
},
|
||||
handleKeyDown
|
||||
handleKeyDown,
|
||||
{ skipEnterWhenFilterHighlighted: highlightedConditionPath !== null }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,12 +163,15 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
|
||||
path={currentPath}
|
||||
isActive={isConditionActive(currentPath)}
|
||||
isOperatorActive={isOperatorActive(currentPath)}
|
||||
isHighlighted={isConditionHighlighted(currentPath)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
<Popover_Shadcn_ open={isActive && !isLoading && items.length > 0}>
|
||||
<Popover_Shadcn_
|
||||
open={isActive && !isLoading && items.length > 0 && !highlightedConditionPath}
|
||||
>
|
||||
<PopoverAnchor_Shadcn_ asChild>
|
||||
{isRootGroup ? (
|
||||
<Input_Shadcn_
|
||||
|
||||
@@ -2,22 +2,29 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { AsyncOptionsFunction, FilterOptionObject, FilterProperty } from './types'
|
||||
import {
|
||||
ActiveInputState,
|
||||
AsyncOptionsFunction,
|
||||
ConditionPath,
|
||||
FilterOptionObject,
|
||||
FilterProperty,
|
||||
} from './types'
|
||||
import { isAsyncOptionsFunction } from './utils'
|
||||
|
||||
export type ActiveInput =
|
||||
| { type: 'value'; path: number[] }
|
||||
| { type: 'operator'; path: number[] }
|
||||
| { type: 'group'; path: number[] }
|
||||
| null
|
||||
export type HighlightNavigationOptions = {
|
||||
skipEnterWhenFilterHighlighted?: boolean
|
||||
}
|
||||
|
||||
export function useFilterBarState() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isCommandMenuVisible, setIsCommandMenuVisible] = useState(false)
|
||||
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [activeInput, setActiveInput] = useState<ActiveInput>(null)
|
||||
const newPathRef = useRef<number[]>([])
|
||||
const [activeInput, setActiveInput] = useState<ActiveInputState>(null)
|
||||
const newPathRef = useRef<ConditionPath>([])
|
||||
const [highlightedConditionPath, setHighlightedConditionPath] = useState<ConditionPath | null>(
|
||||
null
|
||||
)
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
@@ -30,6 +37,8 @@ export function useFilterBarState() {
|
||||
activeInput,
|
||||
setActiveInput,
|
||||
newPathRef,
|
||||
highlightedConditionPath,
|
||||
setHighlightedConditionPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +131,8 @@ export function useDeferredBlur(wrapperRef: React.RefObject<HTMLElement>, onBlur
|
||||
export function useHighlightNavigation(
|
||||
itemsLength: number,
|
||||
onEnter: (index: number) => void,
|
||||
fallbackKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
fallbackKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void,
|
||||
options?: HighlightNavigationOptions
|
||||
) {
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||
|
||||
@@ -145,7 +155,11 @@ export function useHighlightNavigation(
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
// Only select dropdown item if there are items available
|
||||
// Edge case: when a filter is highlighted, skip dropdown selection and let fallback handle it
|
||||
if (options?.skipEnterWhenFilterHighlighted) {
|
||||
if (fallbackKeyDown) fallbackKeyDown(e)
|
||||
return
|
||||
}
|
||||
if (itemsLength > 0) {
|
||||
e.preventDefault()
|
||||
onEnter(highlightedIndex)
|
||||
@@ -160,7 +174,13 @@ export function useHighlightNavigation(
|
||||
}
|
||||
if (fallbackKeyDown) fallbackKeyDown(e)
|
||||
},
|
||||
[itemsLength, highlightedIndex, onEnter, fallbackKeyDown]
|
||||
[
|
||||
itemsLength,
|
||||
highlightedIndex,
|
||||
onEnter,
|
||||
fallbackKeyDown,
|
||||
options?.skipEnterWhenFilterHighlighted,
|
||||
]
|
||||
)
|
||||
|
||||
const reset = useCallback(() => setHighlightedIndex(0), [])
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ActiveInput } from './hooks'
|
||||
import { FilterBarAction, FilterGroup, FilterProperty } from './types'
|
||||
import { ActiveInputState, FilterBarAction, FilterGroup, FilterProperty } from './types'
|
||||
import {
|
||||
findConditionByPath,
|
||||
isCustomOptionObject,
|
||||
@@ -19,7 +18,7 @@ export type MenuItem = {
|
||||
}
|
||||
|
||||
export function buildOperatorItems(
|
||||
activeInput: Extract<ActiveInput, { type: 'operator' }> | null,
|
||||
activeInput: Extract<ActiveInputState, { type: 'operator' }> | null,
|
||||
activeFilters: FilterGroup,
|
||||
filterProperties: FilterProperty[],
|
||||
hasTypedSinceFocus: boolean = true
|
||||
@@ -89,7 +88,7 @@ export function buildPropertyItems(params: {
|
||||
}
|
||||
|
||||
export function buildValueItems(
|
||||
activeInput: Extract<ActiveInput, { type: 'value' }> | null,
|
||||
activeInput: Extract<ActiveInputState, { type: 'value' }> | null,
|
||||
activeFilters: FilterGroup,
|
||||
filterProperties: FilterProperty[],
|
||||
propertyOptionsCache: Record<string, { options: any[]; searchValue: string }>,
|
||||
|
||||
@@ -51,13 +51,15 @@ export function isGroup(condition: FilterCondition | FilterGroup): condition is
|
||||
return 'logicalOperator' in condition
|
||||
}
|
||||
|
||||
export type ConditionPath = number[]
|
||||
|
||||
export type FilterBarAction = {
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
onSelect: (
|
||||
inputValue: string,
|
||||
context: { path: number[]; activeFilters: FilterGroup }
|
||||
context: { path: ConditionPath; activeFilters: FilterGroup }
|
||||
) => void | Promise<void>
|
||||
}
|
||||
|
||||
@@ -71,5 +73,24 @@ export type SerializableFilterProperty = Pick<
|
||||
export type AIFilterRequestPayload = {
|
||||
prompt: string
|
||||
filterProperties: SerializableFilterProperty[]
|
||||
currentPath: number[]
|
||||
currentPath: ConditionPath
|
||||
}
|
||||
|
||||
export type NavigationDirection = 'prev' | 'next'
|
||||
|
||||
export type HighlightNavigationResult = ConditionPath | 'clear' | null
|
||||
|
||||
export type ActiveInputState =
|
||||
| { type: 'value'; path: ConditionPath }
|
||||
| { type: 'operator'; path: ConditionPath }
|
||||
| { type: 'group'; path: ConditionPath }
|
||||
| null
|
||||
|
||||
export type KeyboardNavigationConfig = {
|
||||
activeInput: ActiveInputState
|
||||
setActiveInput: (input: ActiveInputState) => void
|
||||
activeFilters: FilterGroup
|
||||
onFilterChange: (filters: FilterGroup) => void
|
||||
highlightedConditionPath: ConditionPath | null
|
||||
setHighlightedConditionPath: (path: ConditionPath | null) => void
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { ActiveInput } from './hooks'
|
||||
import { FilterGroup, FilterProperty, isGroup } from './types'
|
||||
import { ActiveInputState, FilterGroup, FilterProperty, isGroup } from './types'
|
||||
import { updateGroupAtPath } from './utils'
|
||||
|
||||
export function useAIFilter({
|
||||
@@ -16,7 +15,7 @@ export function useAIFilter({
|
||||
setError,
|
||||
setIsCommandMenuVisible,
|
||||
}: {
|
||||
activeInput: ActiveInput
|
||||
activeInput: ActiveInputState
|
||||
aiApiUrl?: string
|
||||
freeformText: string
|
||||
filterProperties: FilterProperty[]
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { ActiveInput } from './hooks'
|
||||
import { MenuItem } from './menuItems'
|
||||
import { FilterGroup, FilterProperty } from './types'
|
||||
import { ActiveInputState, FilterGroup, FilterProperty } from './types'
|
||||
import { addFilterToGroup, addGroupToGroup, findGroupByPath, isCustomOptionObject } from './utils'
|
||||
|
||||
export function useCommandHandling({
|
||||
@@ -18,8 +17,8 @@ export function useCommandHandling({
|
||||
newPathRef,
|
||||
setIsCommandMenuVisible,
|
||||
}: {
|
||||
activeInput: ActiveInput
|
||||
setActiveInput: (input: ActiveInput) => void
|
||||
activeInput: ActiveInputState
|
||||
setActiveInput: (input: ActiveInputState) => void
|
||||
activeFilters: FilterGroup
|
||||
onFilterChange: (filters: FilterGroup) => void
|
||||
filterProperties: FilterProperty[]
|
||||
|
||||
@@ -2,8 +2,7 @@ import { Sparkles } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { ActiveInput } from './hooks'
|
||||
import { FilterGroup, FilterProperty } from './types'
|
||||
import { ActiveInputState, FilterGroup, FilterProperty } from './types'
|
||||
import {
|
||||
findConditionByPath,
|
||||
isCustomOptionObject,
|
||||
@@ -31,7 +30,7 @@ export function useCommandMenu({
|
||||
aiApiUrl,
|
||||
supportsOperators,
|
||||
}: {
|
||||
activeInput: ActiveInput
|
||||
activeInput: ActiveInputState
|
||||
freeformText: string
|
||||
activeFilters: FilterGroup
|
||||
filterProperties: FilterProperty[]
|
||||
@@ -94,7 +93,7 @@ export function useCommandMenu({
|
||||
}
|
||||
|
||||
function getOperatorItems(
|
||||
activeInput: Extract<ActiveInput, { type: 'operator' }>,
|
||||
activeInput: Extract<ActiveInputState, { type: 'operator' }>,
|
||||
activeFilters: FilterGroup,
|
||||
filterProperties: FilterProperty[]
|
||||
): CommandItem[] {
|
||||
@@ -117,7 +116,7 @@ function getOperatorItems(
|
||||
}
|
||||
|
||||
function getInputValue(
|
||||
activeInput: ActiveInput,
|
||||
activeInput: ActiveInputState,
|
||||
freeformText: string,
|
||||
activeFilters: FilterGroup
|
||||
): string {
|
||||
@@ -138,7 +137,7 @@ function getPropertyItems(filterProperties: FilterProperty[], inputValue: string
|
||||
}
|
||||
|
||||
function getValueItems(
|
||||
activeInput: Extract<ActiveInput, { type: 'value' }>,
|
||||
activeInput: Extract<ActiveInputState, { type: 'value' }>,
|
||||
activeFilters: FilterGroup,
|
||||
filterProperties: FilterProperty[],
|
||||
propertyOptionsCache: Record<string, { options: any[]; searchValue: string }>,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { KeyboardEvent, useCallback } from 'react'
|
||||
|
||||
import { ActiveInput } from './hooks'
|
||||
import { FilterGroup } from './types'
|
||||
import {
|
||||
ConditionPath,
|
||||
HighlightNavigationResult,
|
||||
KeyboardNavigationConfig,
|
||||
NavigationDirection,
|
||||
} from './types'
|
||||
import { findConditionByPath, findGroupByPath, removeFromGroup } from './utils'
|
||||
|
||||
export function useKeyboardNavigation({
|
||||
@@ -9,14 +13,11 @@ export function useKeyboardNavigation({
|
||||
setActiveInput,
|
||||
activeFilters,
|
||||
onFilterChange,
|
||||
}: {
|
||||
activeInput: ActiveInput
|
||||
setActiveInput: (input: ActiveInput) => void
|
||||
activeFilters: FilterGroup
|
||||
onFilterChange: (filters: FilterGroup) => void
|
||||
}) {
|
||||
highlightedConditionPath,
|
||||
setHighlightedConditionPath,
|
||||
}: KeyboardNavigationConfig) {
|
||||
const removeByPath = useCallback(
|
||||
(path: number[]) => {
|
||||
(path: ConditionPath) => {
|
||||
const updatedFilters = removeFromGroup(activeFilters, path)
|
||||
onFilterChange(updatedFilters)
|
||||
},
|
||||
@@ -24,7 +25,7 @@ export function useKeyboardNavigation({
|
||||
)
|
||||
|
||||
const findFirstConditionInGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
(groupPath: ConditionPath): ConditionPath | null => {
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
if (!group || group.conditions.length === 0) return null
|
||||
|
||||
@@ -38,7 +39,7 @@ export function useKeyboardNavigation({
|
||||
)
|
||||
|
||||
const findLastConditionInGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
(groupPath: ConditionPath): ConditionPath | null => {
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
if (!group || group.conditions.length === 0) return null
|
||||
|
||||
@@ -53,7 +54,7 @@ export function useKeyboardNavigation({
|
||||
)
|
||||
|
||||
const findPreviousCondition = useCallback(
|
||||
(currentPath: number[]): number[] | null => {
|
||||
(currentPath: ConditionPath): ConditionPath | null => {
|
||||
const groupPath = currentPath.slice(0, -1)
|
||||
const conditionIndex = currentPath[currentPath.length - 1]
|
||||
|
||||
@@ -78,82 +79,47 @@ export function useKeyboardNavigation({
|
||||
[activeFilters, findLastConditionInGroup]
|
||||
)
|
||||
|
||||
const findNextCondition = useCallback(
|
||||
(currentPath: number[]): number[] | null => {
|
||||
const groupPath = currentPath.slice(0, -1)
|
||||
const conditionIndex = currentPath[currentPath.length - 1]
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
const getHighlightNavigationPath = useCallback(
|
||||
(direction: NavigationDirection): HighlightNavigationResult => {
|
||||
if (activeInput?.type !== 'group') return null
|
||||
|
||||
if (group && conditionIndex < group.conditions.length - 1) {
|
||||
const nextPath = [...groupPath, conditionIndex + 1]
|
||||
const nextCondition = group.conditions[conditionIndex + 1]
|
||||
if (!('logicalOperator' in nextCondition)) {
|
||||
return nextPath
|
||||
const group = findGroupByPath(activeFilters, activeInput.path)
|
||||
if (!group || group.conditions.length === 0) return null
|
||||
|
||||
if (highlightedConditionPath) {
|
||||
const currentIndex = highlightedConditionPath[highlightedConditionPath.length - 1]
|
||||
|
||||
if (direction === 'prev') {
|
||||
if (currentIndex > 0) {
|
||||
return [...activeInput.path, currentIndex - 1]
|
||||
}
|
||||
return 'clear'
|
||||
} else {
|
||||
if (currentIndex < group.conditions.length - 1) {
|
||||
return [...activeInput.path, currentIndex + 1]
|
||||
}
|
||||
return 'clear'
|
||||
}
|
||||
return findFirstConditionInGroup(nextPath)
|
||||
} else {
|
||||
if (direction === 'prev') {
|
||||
return [...activeInput.path, group.conditions.length - 1]
|
||||
}
|
||||
// 'next' with no highlight does nothing - user is already at the rightmost position
|
||||
return null
|
||||
}
|
||||
|
||||
if (groupPath.length > 0) {
|
||||
return findNextCondition(groupPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters, findFirstConditionInGroup]
|
||||
[activeInput, activeFilters, highlightedConditionPath]
|
||||
)
|
||||
|
||||
const findPreviousConditionFromGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
if (group && group.conditions.length > 0) {
|
||||
return findLastConditionInGroup(groupPath)
|
||||
const applyHighlightNavigation = useCallback(
|
||||
(result: HighlightNavigationResult) => {
|
||||
if (result === 'clear') {
|
||||
setHighlightedConditionPath(null)
|
||||
} else if (result) {
|
||||
setHighlightedConditionPath(result)
|
||||
}
|
||||
|
||||
if (groupPath.length > 0) {
|
||||
const parentPath = groupPath.slice(0, -1)
|
||||
const groupIndex = groupPath[groupPath.length - 1]
|
||||
if (groupIndex > 0) {
|
||||
const prevSiblingPath = [...parentPath, groupIndex - 1]
|
||||
const parentGroup = findGroupByPath(activeFilters, parentPath)
|
||||
const prevSibling = parentGroup?.conditions[groupIndex - 1]
|
||||
if (prevSibling) {
|
||||
if ('logicalOperator' in prevSibling) {
|
||||
return findLastConditionInGroup(prevSiblingPath)
|
||||
} else {
|
||||
return prevSiblingPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return findPreviousConditionFromGroup(parentPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters, findLastConditionInGroup]
|
||||
)
|
||||
|
||||
const findNextConditionFromGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
if (groupPath.length > 0) {
|
||||
const parentPath = groupPath.slice(0, -1)
|
||||
const groupIndex = groupPath[groupPath.length - 1]
|
||||
const parentGroup = findGroupByPath(activeFilters, parentPath)
|
||||
|
||||
if (parentGroup && groupIndex < parentGroup.conditions.length - 1) {
|
||||
const nextSiblingPath = [...parentPath, groupIndex + 1]
|
||||
const nextSibling = parentGroup.conditions[groupIndex + 1]
|
||||
if ('logicalOperator' in nextSibling) {
|
||||
return findFirstConditionInGroup(nextSiblingPath)
|
||||
} else {
|
||||
return nextSiblingPath
|
||||
}
|
||||
}
|
||||
return findNextConditionFromGroup(parentPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters, findFirstConditionInGroup]
|
||||
[setHighlightedConditionPath]
|
||||
)
|
||||
|
||||
const handleBackspace = useCallback(
|
||||
@@ -165,14 +131,19 @@ export function useKeyboardNavigation({
|
||||
|
||||
if (activeInput?.type === 'group' && isEmpty) {
|
||||
e.preventDefault()
|
||||
const group = findGroupByPath(activeFilters, activeInput.path)
|
||||
|
||||
if (group && group.conditions.length > 0) {
|
||||
const lastConditionPath = [...activeInput.path, group.conditions.length - 1]
|
||||
removeByPath(lastConditionPath)
|
||||
setActiveInput({ type: 'group', path: activeInput.path })
|
||||
} else if (group && group.conditions.length === 0 && activeInput.path.length > 0) {
|
||||
// Only remove nested empty groups, not the root group
|
||||
if (highlightedConditionPath) {
|
||||
removeByPath(highlightedConditionPath)
|
||||
setHighlightedConditionPath(null)
|
||||
} else {
|
||||
const result = getHighlightNavigationPath('prev')
|
||||
applyHighlightNavigation(result)
|
||||
}
|
||||
} else if (activeInput?.type === 'group' && activeInput.path.length > 0) {
|
||||
// Edge case: remove nested empty groups, but not the root group
|
||||
const group = findGroupByPath(activeFilters, activeInput.path)
|
||||
if (group && group.conditions.length === 0) {
|
||||
e.preventDefault()
|
||||
removeByPath(activeInput.path)
|
||||
setActiveInput({
|
||||
type: 'group',
|
||||
@@ -187,66 +158,90 @@ export function useKeyboardNavigation({
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeInput, activeFilters, removeByPath, setActiveInput]
|
||||
[
|
||||
activeInput,
|
||||
activeFilters,
|
||||
removeByPath,
|
||||
setActiveInput,
|
||||
highlightedConditionPath,
|
||||
setHighlightedConditionPath,
|
||||
getHighlightNavigationPath,
|
||||
applyHighlightNavigation,
|
||||
]
|
||||
)
|
||||
|
||||
const handleArrowLeft = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
if (inputElement.selectionStart === 0) {
|
||||
const isEmpty = inputElement.value === ''
|
||||
|
||||
if (activeInput?.type === 'group' && isEmpty) {
|
||||
e.preventDefault()
|
||||
if (activeInput?.type === 'value') {
|
||||
const prevPath = findPreviousCondition(activeInput.path)
|
||||
if (prevPath) {
|
||||
setActiveInput({ type: 'value', path: prevPath })
|
||||
}
|
||||
} else if (activeInput?.type === 'group') {
|
||||
const prevPath = findPreviousConditionFromGroup(activeInput.path)
|
||||
if (prevPath) {
|
||||
setActiveInput({ type: 'value', path: prevPath })
|
||||
}
|
||||
const result = getHighlightNavigationPath('prev')
|
||||
applyHighlightNavigation(result)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeInput?.type === 'value' && inputElement.selectionStart === 0) {
|
||||
e.preventDefault()
|
||||
const prevPath = findPreviousCondition(activeInput.path)
|
||||
if (prevPath) {
|
||||
setActiveInput({ type: 'value', path: prevPath })
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeInput, findPreviousCondition, findPreviousConditionFromGroup, setActiveInput]
|
||||
[
|
||||
activeInput,
|
||||
getHighlightNavigationPath,
|
||||
applyHighlightNavigation,
|
||||
findPreviousCondition,
|
||||
setActiveInput,
|
||||
]
|
||||
)
|
||||
|
||||
const handleArrowRight = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
if (inputElement.selectionStart === inputElement.value.length) {
|
||||
e.preventDefault()
|
||||
if (activeInput?.type === 'value') {
|
||||
const groupPath = activeInput.path.slice(0, -1)
|
||||
const conditionIndex = activeInput.path[activeInput.path.length - 1]
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
const isEmpty = inputElement.value === ''
|
||||
|
||||
if (group && conditionIndex < group.conditions.length - 1) {
|
||||
const nextCondition = group.conditions[conditionIndex + 1]
|
||||
if ('logicalOperator' in nextCondition) {
|
||||
const nextPath = findFirstConditionInGroup([...groupPath, conditionIndex + 1])
|
||||
if (nextPath) {
|
||||
setActiveInput({ type: 'value', path: nextPath })
|
||||
}
|
||||
} else {
|
||||
setActiveInput({ type: 'value', path: [...groupPath, conditionIndex + 1] })
|
||||
if (activeInput?.type === 'group' && isEmpty && highlightedConditionPath) {
|
||||
e.preventDefault()
|
||||
const result = getHighlightNavigationPath('next')
|
||||
applyHighlightNavigation(result)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
activeInput?.type === 'value' &&
|
||||
inputElement.selectionStart === inputElement.value.length
|
||||
) {
|
||||
e.preventDefault()
|
||||
const groupPath = activeInput.path.slice(0, -1)
|
||||
const conditionIndex = activeInput.path[activeInput.path.length - 1]
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
|
||||
if (group && conditionIndex < group.conditions.length - 1) {
|
||||
const nextCondition = group.conditions[conditionIndex + 1]
|
||||
if ('logicalOperator' in nextCondition) {
|
||||
const nextPath = findFirstConditionInGroup([...groupPath, conditionIndex + 1])
|
||||
if (nextPath) {
|
||||
setActiveInput({ type: 'value', path: nextPath })
|
||||
}
|
||||
} else {
|
||||
setActiveInput({ type: 'group', path: groupPath })
|
||||
}
|
||||
} else if (activeInput?.type === 'group') {
|
||||
const nextPath = findNextConditionFromGroup(activeInput.path)
|
||||
if (nextPath) {
|
||||
setActiveInput({ type: 'value', path: nextPath })
|
||||
setActiveInput({ type: 'value', path: [...groupPath, conditionIndex + 1] })
|
||||
}
|
||||
} else {
|
||||
setActiveInput({ type: 'group', path: groupPath })
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeInput,
|
||||
activeFilters,
|
||||
highlightedConditionPath,
|
||||
getHighlightNavigationPath,
|
||||
applyHighlightNavigation,
|
||||
findFirstConditionInGroup,
|
||||
findNextConditionFromGroup,
|
||||
setActiveInput,
|
||||
]
|
||||
)
|
||||
@@ -260,12 +255,21 @@ export function useKeyboardNavigation({
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
handleArrowRight(e)
|
||||
} else if (e.key === 'Escape') {
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
if (activeElement && activeElement.blur) {
|
||||
activeElement.blur()
|
||||
if (highlightedConditionPath) {
|
||||
e.preventDefault()
|
||||
setHighlightedConditionPath(null)
|
||||
} else {
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
if (activeElement && activeElement.blur) {
|
||||
activeElement.blur()
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
if (activeInput?.type === 'value') {
|
||||
if (highlightedConditionPath) {
|
||||
e.preventDefault()
|
||||
setActiveInput({ type: 'value', path: highlightedConditionPath })
|
||||
setHighlightedConditionPath(null)
|
||||
} else if (activeInput?.type === 'value') {
|
||||
e.preventDefault()
|
||||
setActiveInput({ type: 'group', path: activeInput.path.slice(0, -1) })
|
||||
} else if (activeInput?.type === 'operator') {
|
||||
@@ -275,7 +279,15 @@ export function useKeyboardNavigation({
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeInput, handleBackspace, handleArrowLeft, handleArrowRight, setActiveInput]
|
||||
[
|
||||
activeInput,
|
||||
handleBackspace,
|
||||
handleArrowLeft,
|
||||
handleArrowRight,
|
||||
setActiveInput,
|
||||
highlightedConditionPath,
|
||||
setHighlightedConditionPath,
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user