From c0f79b15f33d7ab50bc888e9c81665ee6a6520ca Mon Sep 17 00:00:00 2001 From: "ilia.brauer" Date: Mon, 25 May 2026 16:14:06 +0200 Subject: [PATCH 1/2] [stories] fixed visible setting for autosuggest after clear query and typing the new one --- .../docs/examples/autosuggest_example.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/stories/patterns/ux-patterns/auto-suggest/docs/examples/autosuggest_example.tsx b/stories/patterns/ux-patterns/auto-suggest/docs/examples/autosuggest_example.tsx index f3a9d18e17..eb1992d9ac 100644 --- a/stories/patterns/ux-patterns/auto-suggest/docs/examples/autosuggest_example.tsx +++ b/stories/patterns/ux-patterns/auto-suggest/docs/examples/autosuggest_example.tsx @@ -67,6 +67,7 @@ const Demo = () => { const [visible, setVisible] = React.useState(false); const [query, setQuery] = React.useState(''); const [suggestions, setSuggestions] = React.useState([]); + const [isEmptyQuery, setIsEmptyQuery] = React.useState(true); const loadSuggestions = React.useCallback( debounce( (query: string) => fakeFetch(query).then((suggestions) => setSuggestions(suggestions)), @@ -75,8 +76,17 @@ const Demo = () => { [], ); React.useEffect(() => { - loadSuggestions(query); - }, [query]); + if (query === '') { + setSuggestions([]); + setIsEmptyQuery(true); + } else { + if (isEmptyQuery) { + setVisible(true); + setIsEmptyQuery(false); + } + loadSuggestions(query); + } + }, [query, isEmptyQuery]); const handleSelect = React.useCallback((x: string) => { setQuery(x); setVisible(false); From 1a93246abb146cf3d7d8a8af2ed3683228bb371b Mon Sep 17 00:00:00 2001 From: "ilia.brauer" Date: Wed, 3 Jun 2026 12:49:44 +0200 Subject: [PATCH 2/2] [select] added new AutoSuggest component --- semcore/select/package.json | 2 +- .../components/AutoSuggest/AutoSuggest.tsx | 146 ++++++++++++++ .../AutoSuggest/AutoSuggest.type.ts | 29 +++ .../src/components/AutoSuggest/Highlight.tsx | 20 ++ semcore/select/src/index.d.ts | 6 +- semcore/select/src/index.js | 1 + .../docs/examples/autosuggest_example.tsx | 189 ++++++------------ 7 files changed, 262 insertions(+), 131 deletions(-) create mode 100644 semcore/select/src/components/AutoSuggest/AutoSuggest.tsx create mode 100644 semcore/select/src/components/AutoSuggest/AutoSuggest.type.ts create mode 100644 semcore/select/src/components/AutoSuggest/Highlight.tsx diff --git a/semcore/select/package.json b/semcore/select/package.json index 3eb66f056b..bd8b0bd0e6 100644 --- a/semcore/select/package.json +++ b/semcore/select/package.json @@ -8,7 +8,7 @@ "author": "UI-kit team ", "license": "MIT", "scripts": { - "build": "pnpm semcore-builder --source=js && pnpm vite build" + "build": "pnpm semcore-builder --source=js,ts && pnpm vite build" }, "exports": { "types": "./lib/types/index.d.ts", diff --git a/semcore/select/src/components/AutoSuggest/AutoSuggest.tsx b/semcore/select/src/components/AutoSuggest/AutoSuggest.tsx new file mode 100644 index 0000000000..5d9c75ab2d --- /dev/null +++ b/semcore/select/src/components/AutoSuggest/AutoSuggest.tsx @@ -0,0 +1,146 @@ +import type { Intergalactic } from '@semcore/core'; +import { Component, createComponent, Root } from '@semcore/core'; +import Input from '@semcore/input'; +import Spin from '@semcore/spin'; +import React from 'react'; + +import type { NSAutoSuggest } from './AutoSuggest.type'; +import { Highlight } from './Highlight'; +import Select from '../../index'; + +class AutoSuggestRoot extends Component< + Intergalactic.InternalTypings.InferComponentProps, + [], + { value: string }, + {}, + NSAutoSuggest.State, + NSAutoSuggest.DefaultProps +> { + static defaultProps: NSAutoSuggest.DefaultProps = { + defaultValue: '', + }; + + private abortController: AbortController | undefined; + private changeDebounce = 0; + + state: NSAutoSuggest.State = { + isVisible: false, + highlightedIndex: -1, + suggestions: [], + openOnChanges: true, + isLoading: false, + }; + + protected uncontrolledProps() { + return { + value: (value: string) => { + return value; + }, + }; + } + + handleChange = (value: string) => { + if (this.changeDebounce) { + clearTimeout(this.changeDebounce); + } + if (this.abortController) { + this.abortController.abort(); + } + + if (value !== this.asProps.value && this.state.openOnChanges) { + const { suggestions } = this.asProps; + + if (!Array.isArray(suggestions)) { + this.setState({ isLoading: true }); + } + + this.changeDebounce = setTimeout(async () => { + this.handleChangeVisible(true); + + if (Array.isArray(suggestions)) { + const filteredSuggestions = value === '' ? [] : suggestions.filter((breed) => breed.toLowerCase().includes(value.toLowerCase())); + + this.setState({ suggestions: filteredSuggestions }); + } else { + this.abortController = new AbortController(); + const abortSignal = this.abortController.signal; + + const filteredSuggestions = await suggestions(value, abortSignal); + this.setState({ suggestions: filteredSuggestions, isLoading: false }); + } + }, 300); + } + }; + + handleChangeVisible = (isVisible: boolean) => { + this.setState({ isVisible }); + }; + + handleChangeHighlightedIndex = (index: number | null) => { + this.setState({ highlightedIndex: index ?? -1 }); + }; + + handleKeyDown = (e: React.KeyboardEvent) => { + if (!e.key.startsWith('Array')) { + this.setState({ highlightedIndex: -1 }); + } + if (e.key === 'Escape' && this.state.isVisible) { + this.setState({ openOnChanges: false }); + } + }; + + handleChangeSelect = (value: string) => { + this.handlers.value(value); + }; + + handleFocus = () => { + const { value } = this.asProps; + this.setState({ openOnChanges: true, isVisible: value === '' }); + }; + + handleBlur = () => { + this.handleChangeVisible(false); + }; + + render() { + const { value } = this.asProps; + const { isVisible, highlightedIndex, suggestions, isLoading } = this.state; + + return ( + + ); + } +} + +export const AutoSuggest = createComponent(AutoSuggestRoot); diff --git a/semcore/select/src/components/AutoSuggest/AutoSuggest.type.ts b/semcore/select/src/components/AutoSuggest/AutoSuggest.type.ts new file mode 100644 index 0000000000..a55e703dea --- /dev/null +++ b/semcore/select/src/components/AutoSuggest/AutoSuggest.type.ts @@ -0,0 +1,29 @@ +import type { Intergalactic } from '@semcore/core'; + +declare namespace NSAutoSuggest { + type Suggestion = string; + + type Props = { + value?: string; + onChange?: (value: string) => void; + suggestions: Suggestion[] | ((value: string, signal: AbortSignal) => Promise); + }; + + type State = { + isVisible: boolean; + highlightedIndex: number; + suggestions: Suggestion[]; + openOnChanges: boolean; + isLoading: boolean; + }; + + type DefaultProps = { + defaultValue: string; + }; + + type Component = Intergalactic.Component<'input', Props>; +} + +export { + NSAutoSuggest, +}; diff --git a/semcore/select/src/components/AutoSuggest/Highlight.tsx b/semcore/select/src/components/AutoSuggest/Highlight.tsx new file mode 100644 index 0000000000..b7d7b80844 --- /dev/null +++ b/semcore/select/src/components/AutoSuggest/Highlight.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +type HighlightProps = { + highlight: string; + children: string; +}; + +export function Highlight({ highlight, children }: HighlightProps) { + let html = children; + if (highlight) { + try { + const re = new RegExp(highlight.toLowerCase(), 'g'); + html = html.replace( + re, + `${highlight}`, + ); + } catch (e) {} + } + return ; +} diff --git a/semcore/select/src/index.d.ts b/semcore/select/src/index.d.ts index ab57acb032..feb4a2bbbc 100644 --- a/semcore/select/src/index.d.ts +++ b/semcore/select/src/index.d.ts @@ -16,6 +16,8 @@ import type Input from '@semcore/input'; import type { Text } from '@semcore/typography'; import type React from 'react'; +import { NSAutoSuggest } from './components/AutoSuggest/AutoSuggest.type.ts'; + export type SelectInputSearch = InputValueProps & {}; export type OptionValue = string | number; @@ -170,5 +172,7 @@ declare const wrapSelect: ( ) => React.ReactNode, ) => IntergalacticSelectComponent; -export { InputSearch, wrapSelect }; +declare const AutoSuggest = NSAutoSuggest.Component; + +export { InputSearch, wrapSelect, AutoSuggest }; export default Select; diff --git a/semcore/select/src/index.js b/semcore/select/src/index.js index 96e699c8ca..843f03a702 100644 --- a/semcore/select/src/index.js +++ b/semcore/select/src/index.js @@ -1,3 +1,4 @@ export { default as InputSearch } from './InputSearch'; export { default } from './Select'; export * from './Select'; +export { AutoSuggest } from './components/AutoSuggest/AutoSuggest'; diff --git a/stories/patterns/ux-patterns/auto-suggest/docs/examples/autosuggest_example.tsx b/stories/patterns/ux-patterns/auto-suggest/docs/examples/autosuggest_example.tsx index 6fd43d6406..95db403c1d 100644 --- a/stories/patterns/ux-patterns/auto-suggest/docs/examples/autosuggest_example.tsx +++ b/stories/patterns/ux-patterns/auto-suggest/docs/examples/autosuggest_example.tsx @@ -1,150 +1,81 @@ import { Box } from '@semcore/ui/base-components'; -import Input from '@semcore/ui/input'; -import Select from '@semcore/ui/select'; +import { AutoSuggest } from '@semcore/ui/select'; import { Text } from '@semcore/ui/typography'; import React from 'react'; -const Highlight = ({ highlight, children }: { highlight: string; children: string }) => { - let html = children; - if (highlight) { - try { - const re = new RegExp(highlight.toLowerCase(), 'g'); - html = html.replace( - re, - `${highlight}`, - ); - } catch (e) {} - } - return ; -}; +const suggestions = [ + 'persian', + 'maine coon', + 'ragdoll', + 'sphynx', + 'siamese', + 'bengal', + 'british shorthair', + 'abyssinian', + 'birman', + 'oriental shorthair', + 'scottish fold', + 'devon rex', + 'norwegian forest', + 'siberian', + 'russian blue', + 'savannah', + 'american shorthair', + 'exotic shorthair', + 'ragamuffin', + 'balinese', +]; -const debounce = (func: Function, timeout: number) => { - let timer: number; - return (...args: any[]) => { - window.clearTimeout(timer); - timer = window.setTimeout(() => { - func(...args); - }, timeout); - }; -}; +const fakeFetch = async (query: string, signal: AbortSignal): Promise => { + if (!query) return []; -type Suggestion = { - value: string; - title: string; -}; + if (signal.aborted) { + return []; + } -const fakeFetch = async (query: string): Promise => { - if (!query) return []; + return new Promise((resolve) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + resolve([]); + }; + signal.addEventListener('abort', onAbort); + + setTimeout(() => { + signal.removeEventListener('abort', onAbort); - return [ - 'persian', - 'maine coon', - 'ragdoll', - 'sphynx', - 'siamese', - 'bengal', - 'british shorthair', - 'abyssinian', - 'birman', - 'oriental shorthair', - 'scottish fold', - 'devon rex', - 'norwegian forest', - 'siberian', - 'russian blue', - 'savannah', - 'american shorthair', - 'exotic shorthair', - 'ragamuffin', - 'balinese', - ] - .filter((breed) => breed.toLowerCase().includes(query.toLowerCase())) - .map((value) => ({ value, title: value })); + resolve(suggestions.filter((breed) => breed.toLowerCase().includes(query.toLowerCase()))); + }, 2000); + }); }; const Demo = () => { - const [highlightedIndex, setHighlightedIndex] = React.useState(-1); - const [visible, setVisible] = React.useState(false); const [query, setQuery] = React.useState(''); - const [suggestions, setSuggestions] = React.useState([]); - const [isEmptyQuery, setIsEmptyQuery] = React.useState(true); - const loadSuggestions = React.useCallback( - debounce( - (query: string) => fakeFetch(query).then((suggestions) => setSuggestions(suggestions)), - 300, - ), - [], - ); - React.useEffect(() => { - if (query === '') { - setSuggestions([]); - setIsEmptyQuery(true); - } else { - if (isEmptyQuery) { - setVisible(true); - setIsEmptyQuery(false); - } - loadSuggestions(query); - } - }, [query, isEmptyQuery]); - const handleSelect = React.useCallback((x: string) => { - setQuery(x); - setVisible(false); - }, []); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (!e.key.startsWith('Array')) { - setHighlightedIndex(-1); - } - }; - - const handleHighlightedIndexChange = (index: number | null) => { - setHighlightedIndex(index); - }; - - const handleChangeVisible = (visible: boolean) => { - setVisible(visible); - }; return ( <> - - Your pet breed + + ASYNC Your pet breed + + + + +
+
+ + SYNC Your pet breed - + id='sync-autosuggest' + onChange={setQuery} + suggestions={suggestions} + /> );