diff --git a/packages/dify-ui/src/autocomplete/index.tsx b/packages/dify-ui/src/autocomplete/index.tsx index 2ef70c955f6..7d5bbe90aab 100644 --- a/packages/dify-ui/src/autocomplete/index.tsx +++ b/packages/dify-ui/src/autocomplete/index.tsx @@ -72,11 +72,10 @@ const autocompleteListClassName = [ ] const autocompleteItemClassName = [ - 'mx-1 flex min-h-8 cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors', + 'mx-1 flex min-h-8 cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden', 'hover:bg-state-base-hover-alt hover:text-text-primary', 'data-highlighted:bg-state-base-hover data-highlighted:text-text-primary', 'data-disabled:cursor-not-allowed data-disabled:opacity-30 data-disabled:hover:bg-transparent data-disabled:hover:text-text-secondary', - 'motion-reduce:transition-none', ] const autocompleteInputGroupVariants = cva( diff --git a/packages/dify-ui/src/combobox/index.tsx b/packages/dify-ui/src/combobox/index.tsx index af4dc77ebcf..bd14a40f9ce 100644 --- a/packages/dify-ui/src/combobox/index.tsx +++ b/packages/dify-ui/src/combobox/index.tsx @@ -79,12 +79,11 @@ const comboboxListClassName = [ ] const comboboxItemClassName = [ - 'grid min-h-8 cursor-pointer select-none grid-cols-[1fr_auto] items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors', + 'grid min-h-8 cursor-pointer select-none grid-cols-[1fr_auto] items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden', 'hover:bg-state-base-hover-alt hover:text-text-primary', 'data-highlighted:bg-state-base-hover data-highlighted:text-text-primary', 'data-selected:text-text-primary', 'data-disabled:cursor-not-allowed data-disabled:opacity-30 data-disabled:hover:bg-transparent data-disabled:hover:text-text-secondary', - 'motion-reduce:transition-none', ] const comboboxTriggerVariants = cva( diff --git a/web/app/education/apply/__tests__/institution-field.spec.tsx b/web/app/education/apply/__tests__/institution-field.spec.tsx index 8165cb31980..4347fdb9ba1 100644 --- a/web/app/education/apply/__tests__/institution-field.spec.tsx +++ b/web/app/education/apply/__tests__/institution-field.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' import InstitutionField from '../institution-field' @@ -7,9 +7,8 @@ const educationAutocompleteQueryMock = vi.hoisted(() => ({ options: vi.fn(), fetchNextPage: vi.fn(), hasNextPage: false, - isFetchingNextPage: false, - isPending: false, - isSuccess: true, + isError: false, + isFetching: false, data: { pages: [ { @@ -21,7 +20,15 @@ const educationAutocompleteQueryMock = vi.hoisted(() => ({ }, })) +const debouncedValueMock = vi.hoisted(() => ({ + hold: false, + value: '', +})) + +const keepPreviousDataMock = vi.hoisted(() => vi.fn((previousData: unknown) => previousData)) + vi.mock('@tanstack/react-query', () => ({ + keepPreviousData: keepPreviousDataMock, useInfiniteQuery: (options: unknown) => { educationAutocompleteQueryMock.options(options) return educationAutocompleteQueryMock @@ -43,7 +50,8 @@ vi.mock('@/service/client', () => ({ })) vi.mock('foxact/use-debounced-value', () => ({ - useDebouncedValue: (value: T) => value, + useDebouncedValue: (value: T) => + debouncedValueMock.hold ? (debouncedValueMock.value as T) : value, })) const ControlledInstitutionField = () => { @@ -51,14 +59,48 @@ const ControlledInstitutionField = () => { return } +function stubIntersectionObserver() { + let callback: IntersectionObserverCallback | undefined + + vi.stubGlobal( + 'IntersectionObserver', + class MockIntersectionObserver { + constructor(nextCallback: IntersectionObserverCallback) { + callback = nextCallback + } + + observe() {} + unobserve() {} + disconnect() {} + }, + ) + + return async () => { + await waitFor(() => { + expect(callback).toBeDefined() + }) + act(() => { + callback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + } +} + describe('InstitutionField', () => { beforeEach(() => { vi.clearAllMocks() educationAutocompleteQueryMock.hasNextPage = false - educationAutocompleteQueryMock.isFetchingNextPage = false - educationAutocompleteQueryMock.isPending = false - educationAutocompleteQueryMock.isSuccess = true + educationAutocompleteQueryMock.isError = false + educationAutocompleteQueryMock.isFetching = false educationAutocompleteQueryMock.data.pages[0]!.data = ['Alpha University', 'Beta College'] + debouncedValueMock.hold = false + debouncedValueMock.value = '' + }) + + afterEach(() => { + vi.unstubAllGlobals() }) it('uses a free-form institution name as the suggestions query', async () => { @@ -115,8 +157,42 @@ describe('InstitutionField', () => { expect(input).toHaveValue('Dify Academy') }) - it('requests the next page when the suggestions reach the scroll boundary', async () => { + it('keeps previous suggestions open while a new search settles', async () => { const user = userEvent.setup() + const view = render() + const input = screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/) + + await user.type(input, 'A') + expect(await screen.findByText('Alpha University')).toBeInTheDocument() + + debouncedValueMock.hold = true + debouncedValueMock.value = 'A' + await user.type(input, 'l') + + expect(input).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Alpha University')).toBeInTheDocument() + + debouncedValueMock.hold = false + educationAutocompleteQueryMock.isFetching = true + view.rerender() + + expect(input).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Alpha University')).toBeInTheDocument() + expect(educationAutocompleteQueryMock.options.mock.lastCall?.[0]).toMatchObject({ + placeholderData: keepPreviousDataMock, + }) + + educationAutocompleteQueryMock.data.pages[0]!.data = ['Alpine University'] + educationAutocompleteQueryMock.isFetching = false + view.rerender() + + expect(screen.getByText('Alpine University')).toBeInTheDocument() + expect(screen.queryByText('Alpha University')).not.toBeInTheDocument() + }) + + it('requests the next page when the suggestions sentinel enters the preload area', async () => { + const user = userEvent.setup() + const triggerIntersection = stubIntersectionObserver() educationAutocompleteQueryMock.hasNextPage = true render() @@ -126,15 +202,50 @@ describe('InstitutionField', () => { 'A', ) - const scrollContainer = await screen.findByRole('listbox') - Object.defineProperties(scrollContainer, { - scrollTop: { value: 60, configurable: true }, - scrollHeight: { value: 100, configurable: true }, - clientHeight: { value: 40, configurable: true }, + await screen.findByRole('listbox') + await triggerIntersection() + + expect(educationAutocompleteQueryMock.fetchNextPage).toHaveBeenCalledOnce() + expect(educationAutocompleteQueryMock.fetchNextPage).toHaveBeenCalledWith({ + cancelRefetch: false, }) + }) - fireEvent.scroll(scrollContainer) + it('keeps keyboard navigation at the loaded list boundary', async () => { + const user = userEvent.setup() + educationAutocompleteQueryMock.hasNextPage = true - expect(educationAutocompleteQueryMock.fetchNextPage).toHaveBeenCalledTimes(1) + render() + + await user.type( + screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/), + 'A', + ) + await screen.findByText('Beta College') + await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}') + + expect(screen.getByText('Beta College').closest('[role="option"]')).toHaveAttribute( + 'data-highlighted', + ) + }) + + it('keeps loaded suggestions visible when the next page fails', async () => { + const user = userEvent.setup() + educationAutocompleteQueryMock.hasNextPage = true + educationAutocompleteQueryMock.isError = true + + render() + + await user.type( + screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/), + 'A', + ) + + expect(await screen.findByText('Alpha University')).toBeInTheDocument() + expect( + screen + .getAllByText('common.dynamicSelect.error') + .some((element) => element.closest('[role="status"]') !== null), + ).toBe(true) }) }) diff --git a/web/app/education/apply/__tests__/page.spec.tsx b/web/app/education/apply/__tests__/page.spec.tsx index 91922ba5409..b690b129d7a 100644 --- a/web/app/education/apply/__tests__/page.spec.tsx +++ b/web/app/education/apply/__tests__/page.spec.tsx @@ -239,6 +239,7 @@ describe('EducationApplyPage billing boundary', () => { screen.getByRole('combobox', { name: 'education.form.schoolName.title' }), 'DifyUniversity', ) + await user.keyboard('{Escape}') expect( screen.getByRole('radiogroup', { name: 'education.form.schoolRole.title' }), ).toBeInTheDocument() diff --git a/web/app/education/apply/institution-field.tsx b/web/app/education/apply/institution-field.tsx index 37fe1725d18..01a8b34fdb1 100644 --- a/web/app/education/apply/institution-field.tsx +++ b/web/app/education/apply/institution-field.tsx @@ -1,7 +1,7 @@ import type { AutocompleteChangeEventDetails } from '@langgenius/dify-ui/autocomplete' -import type { UIEventHandler } from 'react' import { Autocomplete, + AutocompleteCollection, AutocompleteContent, AutocompleteEmpty, AutocompleteInput, @@ -12,10 +12,11 @@ import { AutocompleteStatus, } from '@langgenius/dify-ui/autocomplete' import { Field, FieldLabel } from '@langgenius/dify-ui/field' -import { useInfiniteQuery } from '@tanstack/react-query' +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' import { useDebouncedValue } from 'foxact/use-debounced-value' -import { useId, useState } from 'react' +import { useId, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel' import Loading from '@/app/components/base/loading' import { consoleQuery } from '@/service/client' @@ -29,31 +30,39 @@ type InstitutionFieldProps = { const InstitutionField = ({ value, onValueChange }: InstitutionFieldProps) => { const { t } = useTranslation() const inputId = useId() + const listRef = useRef(null) const [isPopupOpen, setIsPopupOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') const debouncedSearchQuery = useDebouncedValue(searchQuery, 300) - const isSearchReady = !!searchQuery && searchQuery === debouncedSearchQuery - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isSuccess } = - useInfiniteQuery({ - ...consoleQuery.account.education.autocomplete.get.infiniteOptions({ - input: (pageParam) => ({ - query: { - keywords: debouncedSearchQuery, - limit: EDUCATION_AUTOCOMPLETE_PAGE_SIZE, - page: Number(pageParam), - }, - }), - initialPageParam: 0, - getNextPageParam: (lastPage, pages) => - lastPage.has_next ? (lastPage.curr_page ?? pages.length - 1) + 1 : undefined, + const hasSearchQuery = !!searchQuery + const isDebouncing = hasSearchQuery && searchQuery !== debouncedSearchQuery + const { data, fetchNextPage, hasNextPage, isError, isFetching } = useInfiniteQuery({ + ...consoleQuery.account.education.autocomplete.get.infiniteOptions({ + input: (pageParam) => ({ + query: { + keywords: debouncedSearchQuery, + limit: EDUCATION_AUTOCOMPLETE_PAGE_SIZE, + page: Number(pageParam), + }, }), - enabled: isSearchReady, - }) - const suggestions = isSearchReady ? (data?.pages.flatMap((page) => page.data ?? []) ?? []) : [] - const isLoading = isPending || isFetchingNextPage - const shouldOpenPopup = isPopupOpen && isSearchReady && (isLoading || isSuccess) - const shouldShowEmpty = shouldOpenPopup && isSuccess && !isLoading && suggestions.length === 0 - const shouldShowLoading = shouldOpenPopup && isLoading + initialPageParam: 0, + getNextPageParam: (lastPage, pages) => + lastPage.has_next ? (lastPage.curr_page ?? pages.length - 1) + 1 : undefined, + }), + enabled: hasSearchQuery && !!debouncedSearchQuery, + placeholderData: keepPreviousData, + }) + const suggestions = hasSearchQuery ? (data?.pages.flatMap((page) => page.data ?? []) ?? []) : [] + const isLoading = isDebouncing || isFetching + const shouldOpenPopup = isPopupOpen && hasSearchQuery + let footerState: 'error' | 'loading' | null = null + if (shouldOpenPopup) { + if (isLoading) footerState = 'loading' + else if (isError) footerState = 'error' + else if (hasNextPage) footerState = 'loading' + } + const shouldShowEmpty = shouldOpenPopup && footerState === null && suggestions.length === 0 + const canLoadMore = shouldOpenPopup && hasNextPage && !isLoading && !isError const handleValueChange = (inputValue: string, eventDetails: AutocompleteChangeEventDetails) => { onValueChange(inputValue) @@ -67,14 +76,6 @@ const InstitutionField = ({ value, onValueChange }: InstitutionFieldProps) => { setIsPopupOpen(!!inputValue) } - const handleScroll: UIEventHandler = (event) => { - const { scrollTop, scrollHeight, clientHeight } = event.currentTarget - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0 - if (!isAtBottom || !hasNextPage || isFetchingNextPage) return - - void fetchNextPage() - } - return ( { onValueChange={handleValueChange} filter={null} mode="list" + loopFocus={false} open={shouldOpenPopup} onOpenChange={setIsPopupOpen} > @@ -102,27 +104,53 @@ const InstitutionField = ({ value, onValueChange }: InstitutionFieldProps) => { - onScroll={handleScroll}> - {(institution) => ( - - {institution} - - )} + + > + {(institution) => ( + + {institution} + + )} + + {footerState !== null ? ( + + ) : null} {shouldShowEmpty ? t(($) => $['form.schoolName.noResults'], { ns: 'education' }) : null} - {shouldShowLoading ? ( - <> - {t(($) => $.loading, { ns: 'appApi' })} - - - ) : null} + + {shouldOpenPopup && isLoading + ? t(($) => $.loading, { ns: 'common' }) + : footerState === 'error' + ? t(($) => $['dynamicSelect.error'], { ns: 'common' }) + : null} +