Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/react/.storybook/a11y-skip-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
"src/components/F0ButtonToggle/__stories__/F0ButtonToggle.stories.tsx": 1,
"src/components/F0DatePicker/__stories__/F0DatePicker.stories.tsx": 1,
"src/components/F0InputField/__stories__/F0InputField.stories.tsx": 1,
"src/components/F0Select/__stories__/F0Select.stories.tsx": 2,
"src/components/F0TextAreaInput/__stories__/F0TextAreaInput.stories.tsx": 1,
"src/components/F0TextInput/__stories__/F0TextInput.stories.tsx": 1,
"src/components/OneCalendar/OneCalendar.stories.tsx": 2,
Expand Down
42 changes: 28 additions & 14 deletions packages/react/src/components/F0SearchInput/F0SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,28 +45,39 @@ const F0SearchInput = forwardRef<HTMLInputElement, F0SearchInputProps>(
) => {
const input = useRef<HTMLInputElement>(null)

const interval = useRef<NodeJS.Timeout | null>(null)

useImperativeHandle(ref, () => input.current as HTMLInputElement)

useEffect(() => {
if (!props.autoFocus) {
if (interval.current) {
clearInterval(interval.current)
}
const element = input.current

if (
!props.autoFocus ||
props.disabled ||
!element ||
document.activeElement === element
) {
return
}

interval.current = setInterval(() => {
input.current?.focus()
let timeout: ReturnType<typeof setTimeout> | undefined
const stopAutoFocus = () => {
if (timeout !== undefined) {
clearTimeout(timeout)
timeout = undefined
}
element.removeEventListener("focus", stopAutoFocus)
}

element.addEventListener("focus", stopAutoFocus)
timeout = setTimeout(() => {
element.focus()
stopAutoFocus()
}, 50)

return () => {
if (interval.current) {
clearInterval(interval.current)
}
stopAutoFocus()
}
}, [props.autoFocus])
}, [props.autoFocus, props.disabled])

const valueToEmitRef = useRef<string | undefined>(undefined)

Expand All @@ -81,8 +92,12 @@ const F0SearchInput = forwardRef<HTMLInputElement, F0SearchInputProps>(
if (valueToEmitRef.current === undefined) {
setTimeout(() => {
if (valueToEmitRef.current !== undefined) {
const shouldRestoreFocus =
document.activeElement === input.current
onChange(valueToEmitRef.current)
input.current?.focus()
if (shouldRestoreFocus) {
input.current?.focus()
}
}
valueToEmitRef.current = undefined
}, debounceTime)
Expand All @@ -108,7 +123,6 @@ const F0SearchInput = forwardRef<HTMLInputElement, F0SearchInputProps>(
onChange={onChangeLocal}
role="searchbox"
size={size}
autoFocus={props.autoFocus}
clearable={clearable}
onBlur={onBlur}
onFocus={onFocus}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { act, fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

import {
act,
fireEvent,
screen,
zeroRender as render,
} from "@/testing/test-utils"

import { F0SearchInput } from "../index"

Expand All @@ -8,6 +14,60 @@ describe("F0SearchInput", () => {
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
})

describe("autofocus behavior", () => {
it("focuses once without reclaiming focus after navigation", () => {
const onChange = vi.fn()
render(
<>
<F0SearchInput autoFocus debounceTime={400} onChange={onChange} />
<button type="button">Next</button>
</>
)

const input = screen.getByRole("searchbox")
const nextButton = screen.getByRole("button", { name: "Next" })

act(() => {
vi.advanceTimersByTime(50)
})
expect(input).toHaveFocus()

fireEvent.change(input, { target: { value: "query" } })
nextButton.focus()

act(() => {
vi.advanceTimersByTime(500)
})

expect(onChange).toHaveBeenCalledWith("query")
expect(nextButton).toHaveFocus()
})

it("cancels a pending retry after the input receives focus", () => {
render(
<>
<F0SearchInput autoFocus />
<button type="button">Next</button>
</>
)

const input = screen.getByRole("searchbox")
const nextButton = screen.getByRole("button", { name: "Next" })

input.focus()
nextButton.focus()
act(() => {
vi.advanceTimersByTime(100)
})

expect(nextButton).toHaveFocus()
})
})

describe("threshold behavior", () => {
it("does not trigger onChange when input length is below threshold", () => {
const onChange = vi.fn()
Expand Down
Loading
Loading