Compare commits
4 Commits
v0.2026040
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fabc2a1d7 | ||
|
|
2f32a1781a | ||
|
|
94ce91457d | ||
|
|
717d97f6f0 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.32
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterGroup } from '../types'
|
||||
|
||||
@@ -135,6 +135,23 @@ describe('FilterBuilder value inputs', () => {
|
||||
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
|
||||
})
|
||||
|
||||
it('keeps filter controls top-aligned when the date preview adds a second line', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-07T12:00:00Z'))
|
||||
|
||||
renderBuilder({
|
||||
all: [{ field: 'created', op: 'after', value: '10 days ago' }],
|
||||
})
|
||||
|
||||
fireEvent.focus(screen.getByTestId('date-value-input'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-preview')).toHaveTextContent('Resolves to March 28, 2026')
|
||||
expect(screen.getByTestId('filter-row')).toHaveClass('items-start')
|
||||
})
|
||||
|
||||
it('filters the field combobox as the user types', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
|
||||
@@ -141,7 +141,7 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
const regexEnabled = regexSupported && condition.regex === true
|
||||
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-start gap-1.5" data-testid="filter-row">
|
||||
<FilterFieldCombobox
|
||||
value={condition.field}
|
||||
fields={fields}
|
||||
|
||||
@@ -54,6 +54,62 @@ function stepHighlightedIndex(current: number, optionCount: number, direction: '
|
||||
return (current - 1 + optionCount) % optionCount
|
||||
}
|
||||
|
||||
function moveHighlightedOption({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
direction,
|
||||
openCombobox,
|
||||
setHighlightedIndex,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
options: string[]
|
||||
direction: 'next' | 'previous'
|
||||
openCombobox: () => void
|
||||
setHighlightedIndex: (updater: number | ((current: number) => number)) => void
|
||||
}) {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, direction))
|
||||
}
|
||||
|
||||
function selectHighlightedOption({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
highlightedIndex,
|
||||
selectOption,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
options: string[]
|
||||
highlightedIndex: number
|
||||
selectOption: (value: string) => void
|
||||
}) {
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
}
|
||||
|
||||
function closeOpenCombobox({
|
||||
event,
|
||||
open,
|
||||
closeCombobox,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
closeCombobox: () => void
|
||||
}) {
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
}
|
||||
|
||||
function handleFilterFieldKeyDown({
|
||||
event,
|
||||
open,
|
||||
@@ -75,32 +131,16 @@ function handleFilterFieldKeyDown({
|
||||
}) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'next'))
|
||||
moveHighlightedOption({ event, open, options, direction: 'next', openCombobox, setHighlightedIndex })
|
||||
return
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'previous'))
|
||||
moveHighlightedOption({ event, open, options, direction: 'previous', openCombobox, setHighlightedIndex })
|
||||
return
|
||||
case 'Enter':
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
selectHighlightedOption({ event, open, options, highlightedIndex, selectOption })
|
||||
return
|
||||
case 'Escape':
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
closeOpenCombobox({ event, open, closeCombobox })
|
||||
return
|
||||
default:
|
||||
return
|
||||
@@ -178,21 +218,27 @@ function FilterFieldPopoverPanel({
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="max-h-60 overflow-y-auto p-1"
|
||||
className="overflow-hidden p-1"
|
||||
style={{ width: contentWidth }}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
data-testid="filter-field-combobox-popover"
|
||||
>
|
||||
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
|
||||
<FilterFieldOptionsList
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={onHighlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<div
|
||||
className="max-h-60 overflow-y-auto overscroll-contain"
|
||||
data-testid="filter-field-combobox-scroll-area"
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
|
||||
<FilterFieldOptionsList
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={onHighlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)
|
||||
|
||||
@@ -19,4 +19,41 @@ describe('FilterFieldCombobox', () => {
|
||||
expect(listbox).toBeInTheDocument()
|
||||
expect(root.contains(listbox)).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the option list inside a dedicated scroll container', () => {
|
||||
render(
|
||||
<FilterFieldCombobox
|
||||
value="status"
|
||||
fields={Array.from({ length: 20 }, (_, index) => `Field ${index + 1}`)}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.focus(screen.getByTestId('filter-field-combobox-input'))
|
||||
|
||||
expect(screen.getByTestId('filter-field-combobox-scroll-area')).toHaveClass(
|
||||
'max-h-60',
|
||||
'overflow-y-auto',
|
||||
'overscroll-contain',
|
||||
)
|
||||
})
|
||||
|
||||
it('stops wheel events from bubbling out of the scroll container', () => {
|
||||
const onWheel = vi.fn()
|
||||
|
||||
render(
|
||||
<div onWheel={onWheel}>
|
||||
<FilterFieldCombobox
|
||||
value="status"
|
||||
fields={Array.from({ length: 20 }, (_, index) => `Field ${index + 1}`)}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.focus(screen.getByTestId('filter-field-combobox-input'))
|
||||
fireEvent.wheel(screen.getByTestId('filter-field-combobox-scroll-area'))
|
||||
|
||||
expect(onWheel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,7 +121,8 @@ export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean
|
||||
}
|
||||
|
||||
export function handleAiPanelKey(e: KeyboardEvent, onToggleAIChat?: () => void): boolean {
|
||||
if (isCommandShiftOnly(e) === false || e.key.toLowerCase() !== 'l' || onToggleAIChat === undefined) return false
|
||||
const matchesAiPanelShortcut = e.code === 'KeyL' || e.key.toLowerCase() === 'l'
|
||||
if (isCommandShiftOnly(e) === false || matchesAiPanelShortcut === false || onToggleAIChat === undefined) return false
|
||||
e.preventDefault()
|
||||
onToggleAIChat()
|
||||
return true
|
||||
|
||||
@@ -2,17 +2,21 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useAppKeyboard } from './useAppKeyboard'
|
||||
|
||||
function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {}) {
|
||||
function fireKey(
|
||||
key: string,
|
||||
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
|
||||
) {
|
||||
fireKeyOnTarget(window, key, mods)
|
||||
}
|
||||
|
||||
function fireKeyOnTarget(
|
||||
target: EventTarget,
|
||||
key: string,
|
||||
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {},
|
||||
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
|
||||
) {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
code: mods.code,
|
||||
altKey: mods.altKey ?? false,
|
||||
metaKey: mods.metaKey ?? false,
|
||||
ctrlKey: mods.ctrlKey ?? false,
|
||||
@@ -237,6 +241,14 @@ describe('useAppKeyboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Shift+L matches by physical key code when the localized key differs', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
fireKey('¬', { code: 'KeyL', metaKey: true, shiftKey: true })
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Ctrl+Shift+L does not trigger toggle AI chat', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
|
||||
Reference in New Issue
Block a user