diff --git a/.changeset/orange-news-flash.md b/.changeset/orange-news-flash.md new file mode 100644 index 00000000000..6e15092c261 --- /dev/null +++ b/.changeset/orange-news-flash.md @@ -0,0 +1,17 @@ +--- +'braid-design-system': minor +--- + +--- +updated: + - Autosuggest +--- + +**Autosuggest:** Add `suggestionHighlight` prop + +Introduces the `suggestionHighlight` prop, which uses the input value to automatically highlight either the `matching` or `remaining` portion of each suggestion. + +**EXAMPLE USAGE:** +```jsx + +``` \ No newline at end of file diff --git a/packages/braid-design-system/package.json b/packages/braid-design-system/package.json index 0120fcbffb7..b798be1415b 100644 --- a/packages/braid-design-system/package.json +++ b/packages/braid-design-system/package.json @@ -193,7 +193,7 @@ "@vanilla-extract/dynamic": "^2.0.3", "@vanilla-extract/sprinkles": "^1.5.1", "assert": "^2.0.0", - "autosuggest-highlight": "^3.1.1", + "autosuggest-highlight": "^3.3.4", "clsx": "^2.1.1", "csstype": "^3.0.6", "dedent": "^1.5.3", diff --git a/packages/braid-design-system/src/lib/components/Autosuggest/Autosuggest.docs.tsx b/packages/braid-design-system/src/lib/components/Autosuggest/Autosuggest.docs.tsx index 26703f50025..8d5be7a03ff 100644 --- a/packages/braid-design-system/src/lib/components/Autosuggest/Autosuggest.docs.tsx +++ b/packages/braid-design-system/src/lib/components/Autosuggest/Autosuggest.docs.tsx @@ -7,12 +7,18 @@ import { TextLink, Text, Strong, - Alert, List, Stack, Heading, + Notice, + Column, + Columns, + TextField, + Inline, } from '../'; import { IconHelp, IconLanguage } from '../icons'; +import { highlightSuggestions } from './Autosuggest'; +import parseHighlights from 'autosuggest-highlight/parse'; export const makeSuggestions = ( suggestions: Array, @@ -329,30 +335,6 @@ const docs: ComponentDocs = { , ), }, - { - label: 'Client-side filtering', - description: ( - <> - - The logic for filtering suggestions typically lives on the server - rather than the client because it’s impractical to send all possible - suggestions over the network. However, when prototyping in Playroom - or working with smaller datasets, you may want to perform this - filtering on the client instead. For this case, we provide a{' '} - filterSuggestions function to make this as painless - as possible. - - - - All examples on this page use the{' '} - filterSuggestions function to demonstrate - real-world filtering behaviour, but this can be safely omitted if - the filtering is being performed server-side. - - - - ), - }, { label: 'Automatic selection', description: ( @@ -482,6 +464,132 @@ const docs: ComponentDocs = { , ), }, + { + label: 'Suggestion highlights', + description: ( + + Suggestion items can be highlighted based on the input value using the{' '} + suggestionHighlight prop. Choose between highlighting + the matching or remaining portion of + each suggestion. + + ), + Example: ({ id, setDefaultState, setState, getState }) => + source( + <> + {setDefaultState('textfield', 'App')} + {setDefaultState('suggestion', 'Apples')} + + + + + {['matching', 'remaining'].map((highlightType) => ( + + + + Highlight {highlightType} + + + {parseHighlights( + getState('suggestion'), + highlightSuggestions( + getState('suggestion'), + getState('textfield'), + highlightType === 'matching' + ? 'matching' + : 'remaining', + ).map(({ start, end }) => [start, end]), + ).map((part, index) => ( + + {part.text} + + ))} + + + + ))} + + + , + ), + code: false, + }, + + { + description: ( + <> + + If suggestionHighlight is not suitable for your use + case, you can provide explicit highlight ranges for each suggestion. + + + ), + Example: ({ id, setDefaultState, getState, setState, resetState }) => + source( + <> + {setDefaultState('value', { text: 'App' })} + + resetState('value')} + suggestions={[ + { + text: 'Apples', + value: 1, + highlights: [{ start: 2, end: 6 }], + }, + { + text: 'Bananas', + value: 2, + highlights: [{ start: 0, end: 3 }], + }, + ]} + /> + , + ), + }, + { + label: 'Client-side filtering', + description: ( + <> + + The logic for filtering suggestions typically lives on the server + rather than the client because it’s impractical to send all possible + suggestions over the network. However, when prototyping in Playroom + or working with smaller datasets, you may want to perform this + filtering on the client instead. + + + For this case, we provide a filterSuggestions{' '} + function to make this as painless as possible. This also handles + highlights for you, using suggestionHighlight set + to matching. + + + If filtering is being performed on the server, this can be safely + omitted. + + + + Most examples on this page use the{' '} + filterSuggestions function to demonstrate + real-world filtering behaviour. + + + + ), + }, { label: 'Clearable suggestions', description: ( diff --git a/packages/braid-design-system/src/lib/components/Autosuggest/Autosuggest.tsx b/packages/braid-design-system/src/lib/components/Autosuggest/Autosuggest.tsx index 1cf891af4c1..96fe31f5713 100644 --- a/packages/braid-design-system/src/lib/components/Autosuggest/Autosuggest.tsx +++ b/packages/braid-design-system/src/lib/components/Autosuggest/Autosuggest.tsx @@ -14,6 +14,7 @@ import React, { } from 'react'; import dedent from 'dedent'; import parseHighlights from 'autosuggest-highlight/parse'; +import matchHighlights from 'autosuggest-highlight/match'; import { Box } from '../Box/Box'; import { Text } from '../Text/Text'; import { Strong } from '../Strong/Strong'; @@ -277,6 +278,8 @@ interface LegacyMessageSuggestion { message: string; } +type HighlightOptions = 'matching' | 'remaining'; + export type AutosuggestBaseProps = Omit< FieldBaseProps, 'value' | 'autoComplete' | 'prefix' @@ -292,6 +295,7 @@ export type AutosuggestBaseProps = Omit< onChange: (value: AutosuggestValue) => void; clearLabel?: string; automaticSelection?: boolean; + suggestionHighlight?: HighlightOptions; hideSuggestionsOnSelection?: boolean; showMobileBackdrop?: boolean; scrollToTopOnMobile?: boolean; @@ -337,6 +341,21 @@ function normaliseNoSuggestionMessage( } } +export function highlightSuggestions( + suggestion: string, + value: string, + variant: HighlightOptions = 'matching', +): SuggestionMatch { + const matches = matchHighlights(suggestion, value); + + const formattedMatches = + variant === 'remaining' + ? matches.map(([_, end]) => ({ start: end, end: suggestion.length })) + : matches.map(([start, end]) => ({ start, end })); + + return formattedMatches; +} + export const Autosuggest = forwardRef(function ( { id, @@ -345,6 +364,7 @@ export const Autosuggest = forwardRef(function ( noSuggestionsMessage: noSuggestionsMessageProp, onChange = noop, automaticSelection = false, + suggestionHighlight, showMobileBackdrop = false, scrollToTopOnMobile = true, hideSuggestionsOnSelection = true, @@ -373,6 +393,23 @@ export const Autosuggest = forwardRef(function ( ); const hasItems = suggestions.length > 0 || Boolean(noSuggestionsMessage); + const hasExplicitHighlights = suggestions.some( + (suggestion) => 'highlights' in suggestion, + ); + + if (process.env.NODE_ENV !== 'production') { + if (suggestionHighlight && hasExplicitHighlights) { + // eslint-disable-next-line no-console + console.warn( + dedent` + In Autosuggest, you are using the "suggestionHighlight" prop with suggestions that have individual highlight ranges. + Your provided highlight ranges will be overridden. + If you want to use your own highlight ranges, remove the "suggestionHighlight" prop. + `, + ); + } + } + // We need a ref regardless so we can imperatively // focus the field when clicking the clear button const defaultRef = useRef(null); @@ -568,7 +605,7 @@ export const Autosuggest = forwardRef(function ( } } // re-running this effect if the suggestionCount changes - // to ensure asychronous updates aren't left out of view. + // to ensure asynchronous updates aren't left out of view. }, [isOpen, isMobile, suggestionCount]); const inputProps = { @@ -804,6 +841,13 @@ export const Autosuggest = forwardRef(function ( ? normalisedSuggestions.map((suggestion, index) => { const { text } = suggestion; const groupHeading = groupHeadingIndexes.get(index); + const highlights = suggestionHighlight + ? highlightSuggestions( + suggestion.text, + value.text, + suggestionHighlight, + ) + : suggestion.highlights; return ( @@ -811,7 +855,10 @@ export const Autosuggest = forwardRef(function ( {groupHeading} ) : null} { diff --git a/packages/braid-design-system/src/lib/components/Autosuggest/filterSuggestions.ts b/packages/braid-design-system/src/lib/components/Autosuggest/filterSuggestions.ts index 9ca17b028f4..628a84ea10f 100644 --- a/packages/braid-design-system/src/lib/components/Autosuggest/filterSuggestions.ts +++ b/packages/braid-design-system/src/lib/components/Autosuggest/filterSuggestions.ts @@ -1,10 +1,10 @@ import assert from 'assert'; -import matchHighlights from 'autosuggest-highlight/match'; -import type { - AutosuggestValue, - Suggestion, - Suggestions, - GroupedSuggestions, +import { + type AutosuggestValue, + type Suggestion, + type Suggestions, + type GroupedSuggestions, + highlightSuggestions, } from './Autosuggest'; type FilterableSuggestion = Omit, 'highlights'>; @@ -14,17 +14,16 @@ type FilterableGroupedSuggestions = Omit< > & { suggestions: Array> }; function matchSuggestion(suggestion: Suggestion, query: string) { - const groupMatches = matchHighlights( + const highlights = highlightSuggestions( suggestion.label ?? suggestion.text, query, - ) as Array<[number, number]>; - - return !groupMatches.length - ? null - : { + ); + return highlights.length + ? { ...suggestion, - highlights: groupMatches.map(([start, end]) => ({ start, end })), - }; + highlights, + } + : null; } type InputValue = string | AutosuggestValue; diff --git a/packages/generate-component-docs/src/__snapshots__/contract.test.ts.snap b/packages/generate-component-docs/src/__snapshots__/contract.test.ts.snap index d0b7de6f62d..bada0cbf16d 100644 --- a/packages/generate-component-docs/src/__snapshots__/contract.test.ts.snap +++ b/packages/generate-component-docs/src/__snapshots__/contract.test.ts.snap @@ -126,6 +126,9 @@ exports[`Autosuggest 1`] = ` secondaryLabel?: ReactNode secondaryMessage?: ReactNode showMobileBackdrop?: boolean + suggestionHighlight?: + | "matching" + | "remaining" suggestions: | (value: AutosuggestValue) => LegacyMessageSuggestion | Suggestions | Suggestions diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45a67485624..92d2f6791ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,7 +195,7 @@ importers: specifier: ^2.0.0 version: 2.1.0 autosuggest-highlight: - specifier: ^3.1.1 + specifier: ^3.3.4 version: 3.3.4 clsx: specifier: ^2.1.1