Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autosuggest: Add "suggestionHighlight" prop #1536

Merged
merged 19 commits into from
Jul 28, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 17 additions & 0 deletions .changeset/orange-news-flash.md
Original file line number Diff line number Diff line change
@@ -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
<Autosuggest suggestionHighlight="matching">
```
2 changes: 1 addition & 1 deletion packages/braid-design-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | { text: string; description?: string }>,
Expand Down Expand Up @@ -329,30 +335,6 @@ const docs: ComponentDocs = {
</>,
),
},
{
label: 'Client-side filtering',
description: (
<>
<Text>
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{' '}
<Strong>filterSuggestions</Strong> function to make this as painless
as possible.
</Text>
<Alert tone="info">
<Text>
All examples on this page use the{' '}
<Strong>filterSuggestions</Strong> function to demonstrate
real-world filtering behaviour, but this can be safely omitted if
the filtering is being performed server-side.
</Text>
</Alert>
</>
),
},
{
label: 'Automatic selection',
description: (
Expand Down Expand Up @@ -482,6 +464,132 @@ const docs: ComponentDocs = {
</>,
),
},
{
label: 'Suggestion highlights',
description: (
<Text>
Suggestion items can be highlighted based on the input value using the{' '}
<Strong>suggestionHighlight</Strong> prop. Choose between highlighting
the <Strong>matching</Strong> or <Strong>remaining</Strong> portion of
each suggestion.
</Text>
),
Example: ({ id, setDefaultState, setState, getState }) =>
source(
<>
{setDefaultState('textfield', 'App')}
{setDefaultState('suggestion', 'Apples')}

<Stack space="large">
<TextField
label="Label"
id={id}
onChange={setState('textfield')}
value={getState('textfield')}
/>
<Columns space="gutter">
{['matching', 'remaining'].map((highlightType) => (
<Column key={highlightType}>
<Stack space="small">
<Text size="small" tone="secondary">
Highlight <Strong>{highlightType}</Strong>
</Text>
<Inline space="none">
{parseHighlights(
getState('suggestion'),
highlightSuggestions(
getState('suggestion'),
getState('textfield'),
highlightType === 'matching'
? 'matching'
: 'remaining',
).map(({ start, end }) => [start, end]),
).map((part, index) => (
<Text
key={index}
weight={part.highlight ? 'strong' : 'regular'}
>
{part.text}
</Text>
))}
</Inline>
</Stack>
</Column>
))}
</Columns>
</Stack>
</>,
),
code: false,
},

{
description: (
<>
<Text>
If <Strong>suggestionHighlight</Strong> is not suitable for your use
case, you can provide explicit highlight ranges for each suggestion.
</Text>
</>
),
Example: ({ id, setDefaultState, getState, setState, resetState }) =>
source(
<>
{setDefaultState('value', { text: 'App' })}

<Autosuggest
label="Label"
id={id}
value={getState('value')}
onChange={setState('value')}
onClear={() => 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: (
<>
<Text>
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.
</Text>
<Text>
For this case, we provide a <Strong>filterSuggestions</Strong>{' '}
function to make this as painless as possible. This also handles
highlights for you, using <Strong>suggestionHighlight</Strong> set
to <Strong>matching</Strong>.
</Text>
<Text>
If filtering is being performed on the server, this can be safely
omitted.
</Text>
<Notice tone="info">
<Text>
Most examples on this page use the{' '}
<Strong>filterSuggestions</Strong> function to demonstrate
real-world filtering behaviour.
</Text>
</Notice>
</>
),
},
{
label: 'Clearable suggestions',
description: (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -277,6 +278,8 @@ interface LegacyMessageSuggestion {
message: string;
}

type HighlightOptions = 'matching' | 'remaining';

export type AutosuggestBaseProps<Value> = Omit<
FieldBaseProps,
'value' | 'autoComplete' | 'prefix'
Expand All @@ -292,6 +295,7 @@ export type AutosuggestBaseProps<Value> = Omit<
onChange: (value: AutosuggestValue<Value>) => void;
clearLabel?: string;
automaticSelection?: boolean;
suggestionHighlight?: HighlightOptions;
hideSuggestionsOnSelection?: boolean;
showMobileBackdrop?: boolean;
scrollToTopOnMobile?: boolean;
Expand Down Expand Up @@ -337,6 +341,21 @@ function normaliseNoSuggestionMessage<Value>(
}
}

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 <Value>(
{
id,
Expand All @@ -345,6 +364,7 @@ export const Autosuggest = forwardRef(function <Value>(
noSuggestionsMessage: noSuggestionsMessageProp,
onChange = noop,
automaticSelection = false,
suggestionHighlight,
showMobileBackdrop = false,
scrollToTopOnMobile = true,
hideSuggestionsOnSelection = true,
Expand Down Expand Up @@ -373,6 +393,23 @@ export const Autosuggest = forwardRef(function <Value>(
);
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<HTMLInputElement | null>(null);
Expand Down Expand Up @@ -568,7 +605,7 @@ export const Autosuggest = forwardRef(function <Value>(
}
}
// 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 = {
Expand Down Expand Up @@ -804,14 +841,24 @@ export const Autosuggest = forwardRef(function <Value>(
? normalisedSuggestions.map((suggestion, index) => {
const { text } = suggestion;
const groupHeading = groupHeadingIndexes.get(index);
const highlights = suggestionHighlight
? highlightSuggestions(
suggestion.text,
value.text,
suggestionHighlight,
)
: suggestion.highlights;

return (
<Fragment key={index + text}>
{groupHeading ? (
<GroupHeading>{groupHeading}</GroupHeading>
) : null}
<SuggestionItem
suggestion={suggestion}
suggestion={{
...suggestion,
highlights,
}}
highlighted={highlightedIndex === index}
selected={value === suggestion}
onClick={() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Value> = Omit<Suggestion<Value>, 'highlights'>;
Expand All @@ -14,17 +14,16 @@ type FilterableGroupedSuggestions<Value> = Omit<
> & { suggestions: Array<FilterableSuggestion<Value>> };

function matchSuggestion<Value>(suggestion: Suggestion<Value>, 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<Value> = string | AutosuggestValue<Value>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ exports[`Autosuggest 1`] = `
secondaryLabel?: ReactNode
secondaryMessage?: ReactNode
showMobileBackdrop?: boolean
suggestionHighlight?:
| "matching"
| "remaining"
suggestions:
| (value: AutosuggestValue<Value>) => LegacyMessageSuggestion | Suggestions<Value>
| Suggestions<Value>
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.