diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45e5ee755e..7d56a218a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1585,6 +1585,9 @@ importers: '@semcore/input': specifier: ^17.2.0 version: link:../input + '@semcore/spin': + specifier: ^17.2.0 + version: link:../spin '@semcore/typography': specifier: ^17.2.0 version: link:../typography @@ -19613,7 +19616,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(happy-dom@9.20.3)(jsdom@22.1.0)(lightningcss@1.31.1)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(happy-dom@9.20.3)(jsdom@22.1.0)(lightningcss@1.31.1)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.48.0)(yaml@2.8.3) '@vitest/utils@3.2.4': dependencies: diff --git a/semcore/dropdown/src/AbstractDropdown.tsx b/semcore/dropdown/src/AbstractDropdown.tsx index 439e53025e..b3320f0575 100644 --- a/semcore/dropdown/src/AbstractDropdown.tsx +++ b/semcore/dropdown/src/AbstractDropdown.tsx @@ -312,10 +312,10 @@ export abstract class AbstractDropdown extends Component", "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", @@ -23,6 +23,7 @@ "@semcore/dropdown": "^17.2.0", "@semcore/dropdown-menu": "^17.2.0", "@semcore/input": "^17.2.0", + "@semcore/spin": "^17.2.0", "@semcore/typography": "^17.2.0", "classnames": "2.2.6" }, diff --git a/semcore/select/src/components/AutoSuggest/AutoSuggest.tsx b/semcore/select/src/components/AutoSuggest/AutoSuggest.tsx new file mode 100644 index 0000000000..21ba2db80c --- /dev/null +++ b/semcore/select/src/components/AutoSuggest/AutoSuggest.tsx @@ -0,0 +1,177 @@ +import type { Intergalactic } from '@semcore/core'; +import { Component, createComponent, Root } from '@semcore/core'; +import i18nEnhance from '@semcore/core/lib/utils/enhances/i18nEnhance'; +import uniqueIDEnhancement from '@semcore/core/lib/utils/uniqueID'; +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'; +import { localizedMessages } from '../../translations/__intergalactic-dynamic-locales'; + +class AutoSuggestRoot extends Component< + Intergalactic.InternalTypings.InferComponentProps, + typeof AutoSuggestRoot.enhance, + { value: (value: string) => string }, + {}, + NSAutoSuggest.State, + NSAutoSuggest.DefaultProps +> { + static defaultProps: NSAutoSuggest.DefaultProps = { + defaultValue: '', + }; + + static enhance = [uniqueIDEnhancement(), i18nEnhance(localizedMessages)] as const; + + 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; + }, + }; + } + + componentDidMount(): void { + document.addEventListener('click', (e) => { + // eslint-disable-next-line no-console + console.log(e.target, e.currentTarget); + }); + } + + 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 = window.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, uid, getI18nText } = this.asProps; + const { isVisible, highlightedIndex, suggestions, isLoading } = this.state; + const id = `${uid}_autosuggest-trigger`; + + const isVisiblePopper = isVisible && (value === '' || suggestions.length > 0 || isLoading); + + 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..c0f5c0ed8a --- /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(), 'ig'); + 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 9003c3a6dc..5bf4fed5e9 100644 --- a/semcore/select/src/index.d.ts +++ b/semcore/select/src/index.d.ts @@ -17,6 +17,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; @@ -172,5 +174,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/semcore/select/src/translations/en.json b/semcore/select/src/translations/en.json index a8a32c02d4..30c237891b 100644 --- a/semcore/select/src/translations/en.json +++ b/semcore/select/src/translations/en.json @@ -2,5 +2,6 @@ "clearSearch": "Clear search field", "selectPlaceholder": "Select option", "Select.InputSearch.Value:placeholder": "Search", - "Select.InputSearch.Value:aria-label": "Search" + "Select.InputSearch.Value:aria-label": "Search", + "AutoSuggest.Popper.placeholderText": "Start typing to see options" } 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 e89356c96e..34f6a38ff8 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,140 +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 loadSuggestions = React.useCallback( - debounce( - (query: string) => fakeFetch(query).then((suggestions) => setSuggestions(suggestions)), - 300, - ), - [], - ); - React.useEffect(() => { - loadSuggestions(query); - }, [query]); - 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} + /> ); diff --git a/stories/patterns/ux-patterns/auto-suggest/tests/AutoSuggest.stories.tsx b/stories/patterns/ux-patterns/auto-suggest/tests/AutoSuggest.stories.tsx new file mode 100644 index 0000000000..47353fe9d3 --- /dev/null +++ b/stories/patterns/ux-patterns/auto-suggest/tests/AutoSuggest.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import AutosuggestTestExample, { autosuggestTestDefaultProps } from './examples/autosuggest_test'; +import type { AutosuggestTestProps } from './examples/autosuggest_test'; + +const meta: Meta = { + title: 'Patterns/UX Patterns/AutoSuggest/Tests', +}; +export default meta; + +export const Autosuggest: StoryObj = { + render: AutosuggestTestExample, + args: autosuggestTestDefaultProps, + argTypes: { + suggestionsSource: { + control: { type: 'radio' }, + options: ['sync', 'async'], + }, + initialValue: { + control: 'text', + }, + asyncDelay: { + control: { type: 'number', min: 0, step: 100 }, + }, + autoFocus: { + control: 'boolean', + }, + width: { + control: { type: 'number', min: 160, step: 20 }, + }, + placeholder: { + control: 'text', + }, + }, +}; diff --git a/stories/patterns/ux-patterns/auto-suggest/tests/examples/autosuggest_test.tsx b/stories/patterns/ux-patterns/auto-suggest/tests/examples/autosuggest_test.tsx new file mode 100644 index 0000000000..2f14073a20 --- /dev/null +++ b/stories/patterns/ux-patterns/auto-suggest/tests/examples/autosuggest_test.tsx @@ -0,0 +1,112 @@ +import { Box } from '@semcore/ui/base-components'; +import { AutoSuggest } from '@semcore/ui/select'; +import { Text } from '@semcore/ui/typography'; +import React from 'react'; + +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', +]; + +export type AutosuggestTestProps = { + suggestionsSource?: 'sync' | 'async'; + initialValue?: string; + asyncDelay?: number; + autoFocus?: boolean; + width?: number; + placeholder?: string; +}; + +export const autosuggestTestDefaultProps: Required = { + suggestionsSource: 'sync', + initialValue: '', + asyncDelay: 1000, + autoFocus: false, + width: 250, + placeholder: 'Start typing to see options', +}; + +const fakeFetch = async (query: string, signal: AbortSignal, delay: number): Promise => { + if (!query) return []; + + if (signal.aborted) { + return []; + } + + return new Promise((resolve) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + resolve([]); + }; + signal.addEventListener('abort', onAbort); + + setTimeout(() => { + signal.removeEventListener('abort', onAbort); + + resolve(suggestions.filter((breed) => breed.toLowerCase().includes(query.toLowerCase()))); + }, delay); + }); +}; + +const Demo = (props: AutosuggestTestProps) => { + const { + suggestionsSource, + initialValue, + asyncDelay, + autoFocus, + width, + placeholder, + } = { + ...autosuggestTestDefaultProps, + ...props, + }; + const [query, setQuery] = React.useState(initialValue); + + React.useEffect(() => { + setQuery(initialValue); + }, [initialValue]); + + const getSuggestions = React.useCallback( + (query: string, signal: AbortSignal) => fakeFetch(query, signal, asyncDelay), + [asyncDelay], + ); + + return ( + <> + + Your pet breed + + + + + + ); +}; + +export default Demo; diff --git a/website/docs/patterns/auto-suggest/static/start.png b/website/docs/patterns/auto-suggest/static/start.png index f0b96a1fa2..586fa1def3 100644 Binary files a/website/docs/patterns/auto-suggest/static/start.png and b/website/docs/patterns/auto-suggest/static/start.png differ