From fc3e60c54119c4473646a01d056f43a287c39930 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 18 Aug 2026 10:42:26 +0300 Subject: [PATCH 1/5] feat(components): add `Highlight` component (DS-4815) --- .storybook/components/Roadmap/data.ts | 3 +- .../components/Autocomplete/Autocomplete.mdx | 8 + .../Autocomplete/Autocomplete.stories.tsx | 35 +++ .../src/components/Highlight/Highlight.mdx | 98 +++++++ .../components/Highlight/Highlight.module.css | 32 +++ .../Highlight/Highlight.stories.tsx | 265 ++++++++++++++++++ .../components/Highlight/Highlight.test.tsx | 188 +++++++++++++ .../src/components/Highlight/Highlight.tsx | 47 ++++ .../src/components/Highlight/index.ts | 2 + .../src/components/Highlight/types.ts | 22 ++ .../src/components/Highlight/utils.ts | 34 +++ .../src/components/Username/Username.mdx | 3 + .../components/Username/Username.stories.tsx | 34 ++- packages/components/src/components/index.ts | 1 + tools/api-extractor/config.json | 1 + .../components/Highlight.api.md | 35 +++ 16 files changed, 800 insertions(+), 8 deletions(-) create mode 100644 packages/components/src/components/Highlight/Highlight.mdx create mode 100644 packages/components/src/components/Highlight/Highlight.module.css create mode 100644 packages/components/src/components/Highlight/Highlight.stories.tsx create mode 100644 packages/components/src/components/Highlight/Highlight.test.tsx create mode 100644 packages/components/src/components/Highlight/Highlight.tsx create mode 100644 packages/components/src/components/Highlight/index.ts create mode 100644 packages/components/src/components/Highlight/types.ts create mode 100644 packages/components/src/components/Highlight/utils.ts create mode 100644 tools/public_api_guard/components/Highlight.api.md diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index c0ba0d730..ee69b50be 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -445,7 +445,8 @@ export const rows: Rows = [ }, { component: 'Highlight', - status: '🚧 Planned', + status: '✅ Done', + stage: '🔵 experimental', planned: 'Q3 2026', }, ]; diff --git a/packages/components/src/components/Autocomplete/Autocomplete.mdx b/packages/components/src/components/Autocomplete/Autocomplete.mdx index d185218e4..4e94412ed 100644 --- a/packages/components/src/components/Autocomplete/Autocomplete.mdx +++ b/packages/components/src/components/Autocomplete/Autocomplete.mdx @@ -94,6 +94,14 @@ The following example uses the `defaultFilter` prop to filter the list of option +### Highlighting matches + +Use [`Highlight`](/docs/components-highlight--docs) to mark the part of an option that matched the query. +Control `inputValue` so the query is available for both filtering and highlighting, and set `textValue` +on the item so the collection keeps a plain-text label for typeahead and accessibility. + + + ## Appearance ### Addons diff --git a/packages/components/src/components/Autocomplete/Autocomplete.stories.tsx b/packages/components/src/components/Autocomplete/Autocomplete.stories.tsx index 6a6fcf6f0..e23956186 100644 --- a/packages/components/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/packages/components/src/components/Autocomplete/Autocomplete.stories.tsx @@ -10,6 +10,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Badge, useFilter } from '../../index'; import { FlexBox } from '../FlexBox'; +import { Highlight } from '../Highlight'; import { ProgressSpinner } from '../ProgressSpinner'; import { Typography } from '../Typography'; @@ -273,6 +274,40 @@ export const CustomFiltering: Story = { }, }; +export const HighlightingMatches: Story = { + render: function Render() { + const items = [ + { key: 'tls', name: 'TLS' }, + { key: 'ssh', name: 'SSH' }, + { key: 'pgp', name: 'PGP' }, + { key: 'ipsec', name: 'IPSec' }, + { key: 'kerberos', name: 'Kerberos' }, + ]; + + const { contains } = useFilter({ sensitivity: 'base' }); + + const [inputValue, setInputValue] = useState(''); + + const filtered = items.filter((item) => contains(item.name, inputValue)); + + return ( + + {(item) => ( + + + + )} + + ); + }, +}; + export const Addons: Story = { render: function Render(args) { return ( diff --git a/packages/components/src/components/Highlight/Highlight.mdx b/packages/components/src/components/Highlight/Highlight.mdx new file mode 100644 index 000000000..974cbf673 --- /dev/null +++ b/packages/components/src/components/Highlight/Highlight.mdx @@ -0,0 +1,98 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './Highlight.stories'; + + + +# Highlight + + + +Highlight marks every occurrence of a search query inside a text. +Marking matches helps users understand why a result is relevant and speeds up +scanning through long lists. + +Matching is case-insensitive, covers all occurrences, and treats the query +literally — regular expression characters such as `.` or `[0]` need no escaping. +Matched fragments are wrapped in a `` element; the surrounding text is +rendered as plain text, so markup inside `text` is never interpreted. + +## Import + +```tsx +import { Highlight } from '@koobiq/react-components'; +``` + +## Usage + +Pass the full string to `text` and the search term to `query`. +When `query` is empty, the text is rendered without highlighting. + + + +## Props + + + +## Variant + +The `variant` prop selects the marking style. + +- `background` — a solid background behind the match. This is the default. + Use it in dropdown pickers with filtering and to mark matches in longer + content: descriptions, messages, articles. The solid background stays legible + in hover and selected states inside overlays. +- `bold` — the match is set in a bolder weight, with no background. + This style is unobtrusive and suits quick scanning of familiar data. + + + +## Root tag + +The component renders an inline `` by default. Use the `as` prop when the +highlighted text is a block of its own, or when it has to be a specific element +such as a table cell. + +```tsx + +``` + +## In text + + + +## In autocomplete + +`Highlight` marks matches; it does not filter. Filtering stays the consumer's +job — `useFilter` from the library provides locale-aware `contains` and +`startsWith` matchers for that. Pass `textValue` on the option so the collection +keeps a plain-text label for typeahead and accessibility. + + + +## In search results + +Highlighting the query in both the title and the snippet makes it obvious which +part of a result matched. + + + +## In a table + + + +## CSS Variables + +Use CSS variables to customize the marked fragment. + +| Variable | +| ---------------------------------- | +| `--kbq-highlight-color` | +| `--kbq-highlight-background-color` | +| `--kbq-highlight-border-radius` | +| `--kbq-highlight-font-weight` | diff --git a/packages/components/src/components/Highlight/Highlight.module.css b/packages/components/src/components/Highlight/Highlight.module.css new file mode 100644 index 000000000..7111cf25b --- /dev/null +++ b/packages/components/src/components/Highlight/Highlight.module.css @@ -0,0 +1,32 @@ +.base { + --highlight-color: ; + --highlight-background-color: ; + --highlight-border-radius: ; + --highlight-font-weight: ; + + /* Kept even when a variant leaves the token empty: the declaration still wins + the cascade and then computes to `unset`, which is what neutralises the UA + `mark { background-color: Mark; color: Marktext }` yellow. */ + .mark { + color: var(--kbq-highlight-color, var(--highlight-color)); + background-color: var( + --kbq-highlight-background-color, + var(--highlight-background-color) + ); + border-radius: var( + --kbq-highlight-border-radius, + var(--highlight-border-radius) + ); + font-weight: var(--kbq-highlight-font-weight, var(--highlight-font-weight)); + } +} + +.background { + --highlight-color: var(--kbq-foreground-contrast); + --highlight-background-color: var(--kbq-background-highlight); + --highlight-border-radius: var(--kbq-size-xxs); +} + +.bold { + --highlight-font-weight: bold; +} diff --git a/packages/components/src/components/Highlight/Highlight.stories.tsx b/packages/components/src/components/Highlight/Highlight.stories.tsx new file mode 100644 index 000000000..3e940a250 --- /dev/null +++ b/packages/components/src/components/Highlight/Highlight.stories.tsx @@ -0,0 +1,265 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { useFilter } from '../../index'; +import { Autocomplete } from '../Autocomplete'; +import { Button } from '../Button'; +import { FlexBox } from '../FlexBox'; +import { Link } from '../Link'; +import { SearchInput } from '../SearchInput'; +import { Table, TableContainer } from '../Table'; +import { Typography } from '../Typography'; + +import { + Highlight, + type HighlightBaseProps, + highlightPropVariant, +} from './index'; + +const meta = { + title: 'Components/Highlight', + component: Highlight, + parameters: { + layout: 'centered', + }, + tags: ['status:new', 'date:2026-08-18'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Base: Story = { + render: (args) => ( + + + + ), +}; + +export const Variant: Story = { + render: (args) => ( + + {highlightPropVariant.map((variant) => ( + + + + + + {variant} + + + ))} + + ), +}; + +export const Text: Story = { + render: (args) => { + const query = 'cub'; + + const text = + 'The cube can be represented in many ways. One example is by drawing a graph,' + + ' a structure in graph theory consisting of a set of vertices that are connected' + + ' with an edge. This graph also represents the family of a cuboid, a polyhedron' + + ' with six quadrilateral faces, which includes the cube as its special case.'; + + return ( + + + + + + + + + ); + }, +}; + +export const InAutocomplete: Story = { + render: function Render(args) { + const clubs = [ + 'Manchester United', + 'Manchester 62 FC', + 'FC United of Manchester', + 'Manchester United Reserves', + 'Manchester United U23', + 'Manchester United Academy', + ].map((name) => ({ key: name, name })); + + const { contains } = useFilter({ sensitivity: 'base' }); + + const [inputValue, setInputValue] = useState(''); + + const items = clubs.filter((club) => contains(club.name, inputValue)); + + return ( +
+ + {(item) => ( + + + + )} + +
+ ); + }, +}; + +export const SearchResults: Story = { + render: function Render(args) { + const results = [ + { + title: 'Cube', + url: 'https://en.wikipedia.org/wiki/Cube', + snippet: + 'A cube is a three-dimensional solid object in geometry. A cube has eight' + + ' vertices and twelve straight edges of the same length, so that these edges' + + ' form six faces.', + }, + { + title: 'Cube (1997 film)', + url: 'https://en.wikipedia.org/wiki/Cube_(1997_film)', + snippet: + 'Cube is a 1997 Canadian science fiction horror film directed and co-written' + + ' by Vincenzo Natali, produced by the Canadian Film Centre First Feature' + + ' Project.', + }, + { + title: 'Cube (algebra)', + url: 'https://en.wikipedia.org/wiki/Cube_(algebra)', + snippet: + 'In arithmetic and algebra, the cube of a number n is its third power,' + + ' that is, the result of multiplying three instances of n together.', + }, + ]; + + const [value, setValue] = useState('cube'); + + const [query, setQuery] = useState('cube'); + + return ( + + + + + + {results.map((result) => ( + + + + + + + + {result.url} + + + + + + ))} + + ); + }, +}; + +export const InTable: Story = { + parameters: { + layout: 'padded', + }, + render: (args) => { + const query = 'manc'; + + const rows = [ + { + city: 'Manchester', + company: 'Blue Harbor Ltd', + person: 'Alice Brown', + notes: 'Annual review completed', + }, + { + city: 'Liverpool', + company: 'Mancraft Studio', + person: 'Daniel Reed', + notes: 'New client onboarded', + }, + { + city: 'Bristol', + company: 'GreenField Tech', + person: 'Mancini Robert', + notes: 'Budget approved', + }, + { + city: 'Leeds', + company: 'Northwind Labs', + person: 'Emma Stone', + notes: 'Visit planned for Manchester office', + }, + { + city: 'York', + company: 'Delta Systems', + person: 'Michael Turner', + notes: 'Contract with Mancorp signed', + }, + { + city: 'Oxford', + company: 'Silver Bridge', + person: 'Laura Green', + notes: 'Archived in mancategory B', + }, + ]; + + return ( + + + + City + Company + Person + Notes + + + {(row) => ( + + + + + + + + + + + + + + + )} + +
+
+ ); + }, +}; diff --git a/packages/components/src/components/Highlight/Highlight.test.tsx b/packages/components/src/components/Highlight/Highlight.test.tsx new file mode 100644 index 000000000..8365c7feb --- /dev/null +++ b/packages/components/src/components/Highlight/Highlight.test.tsx @@ -0,0 +1,188 @@ +import { createRef } from 'react'; + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Highlight, highlightPropVariant } from './index'; + +describe('Highlight', () => { + const baseProps = { 'data-testid': 'highlight' }; + + const getRoot = () => screen.getByTestId('highlight'); + + const getMarks = () => Array.from(getRoot().querySelectorAll('mark')); + + const getMarkTexts = () => getMarks().map((mark) => mark.textContent); + + it('should forward a ref to the root element', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBe(getRoot()); + }); + + it('should merge a custom class name with the default ones', () => { + render(); + + expect(getRoot()).toHaveClass('foo'); + }); + + it('should spread additional HTML props onto the root element', () => { + render(); + + expect(getRoot()).toHaveAttribute('aria-label', 'Match'); + }); + + it('should render the component as a custom root tag', () => { + render(); + + expect(getRoot().tagName).toBe('DIV'); + }); + + it('should set data-variant to "background" by default', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-variant', 'background'); + }); + + describe('check the variant prop', () => { + it.each(highlightPropVariant)( + 'should apply the variant as a "%s"', + (variant) => { + render(); + + expect(getRoot()).toHaveAttribute('data-variant', variant); + } + ); + }); + + describe('without highlighting', () => { + it('should render the text as is when the query is undefined', () => { + render(); + + expect(getRoot()).toHaveTextContent('Hello world'); + expect(getMarks()).toHaveLength(0); + }); + + it('should render the text as is when the query is empty', () => { + render(); + + expect(getRoot()).toHaveTextContent('Hello world'); + expect(getMarks()).toHaveLength(0); + }); + + it('should render the text as is when the query is not found', () => { + render(); + + expect(getRoot()).toHaveTextContent('Hello world'); + expect(getMarks()).toHaveLength(0); + }); + + it('should render nothing when the text is undefined', () => { + render(); + + expect(getRoot()).toBeEmptyDOMElement(); + }); + }); + + describe('matching', () => { + it('should wrap the match in a mark element', () => { + render(); + + expect(getMarkTexts()).toEqual(['world']); + expect(getRoot().textContent).toBe('Hello world'); + }); + + it('should highlight all occurrences', () => { + render(); + + expect(getMarkTexts()).toEqual(['ab', 'ab', 'ab']); + }); + + it('should highlight adjacent repeated matches', () => { + render(); + + expect(getMarkTexts()).toEqual(['a', 'a', 'a']); + }); + + it('should highlight a substring inside a word', () => { + render(); + + expect(getMarkTexts()).toEqual(['ell']); + }); + + it('should highlight a match at the start of the text', () => { + render(); + + expect(getMarkTexts()).toEqual(['He']); + expect(getRoot().textContent).toBe('Hello'); + }); + + it('should highlight a match at the end of the text', () => { + render(); + + expect(getMarkTexts()).toEqual(['lo']); + expect(getRoot().textContent).toBe('Hello'); + }); + + it('should highlight the whole text when it fully matches', () => { + render(); + + expect(getMarkTexts()).toEqual(['test']); + }); + + it('should preserve the whitespace surrounding a match', () => { + render(); + + expect(getRoot().textContent).toBe(' a '); + expect(getMarkTexts()).toEqual(['a']); + }); + }); + + describe('case-insensitive matching', () => { + it('should be case insensitive for latin text', () => { + render(); + + expect(getMarkTexts()).toEqual(['World']); + }); + + it('should be case insensitive for cyrillic text', () => { + render(); + + expect(getMarkTexts()).toEqual(['Привет']); + }); + + it('should highlight a cyrillic substring', () => { + render(); + + expect(getMarkTexts()).toEqual(['вет']); + expect(getRoot().textContent).toBe('Привет мир'); + }); + }); + + describe('regular expression special characters in the query', () => { + it.each([ + ['price is $100', '$100', '$100'], + ['call()', 'call()', 'call()'], + ['file.ts', '.', '.'], + ['arr[0]', '[0]', '[0]'], + ])( + 'should treat the query "%s" literally', + (text, query, expectedMatch) => { + render(); + + expect(getMarkTexts()).toEqual([expectedMatch]); + expect(getRoot().textContent).toBe(text); + } + ); + }); + + it('should render HTML in the text as plain characters', () => { + render(); + + expect(getRoot().textContent).toBe('bold'); + expect(getRoot().querySelector('b')).toBeNull(); + expect(getMarkTexts()).toEqual(['bold']); + }); +}); diff --git a/packages/components/src/components/Highlight/Highlight.tsx b/packages/components/src/components/Highlight/Highlight.tsx new file mode 100644 index 000000000..2f7923bf8 --- /dev/null +++ b/packages/components/src/components/Highlight/Highlight.tsx @@ -0,0 +1,47 @@ +'use client'; + +import type { ComponentPropsWithRef, ElementType } from 'react'; + +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; + +import s from './Highlight.module.css'; +import type { HighlightBaseProps } from './types'; +import { splitByQuery } from './utils'; + +/** Highlight marks every occurrence of a search query inside a text. */ +export const Highlight = polymorphicForwardRef<'span', HighlightBaseProps>( + (props, ref) => { + const { + as: Tag = 'span', + variant = 'background', + text, + query, + className, + ...other + } = props; + + const parts = splitByQuery(text, query); + + return ( + + {parts.map((part, index) => + part.isMatch ? ( + + {part.text} + + ) : ( + part.text + ) + )} + + ); + } +); + +export type HighlightProps = + ComponentPropsWithRef>; diff --git a/packages/components/src/components/Highlight/index.ts b/packages/components/src/components/Highlight/index.ts new file mode 100644 index 000000000..eeaef38d8 --- /dev/null +++ b/packages/components/src/components/Highlight/index.ts @@ -0,0 +1,2 @@ +export * from './Highlight'; +export * from './types'; diff --git a/packages/components/src/components/Highlight/types.ts b/packages/components/src/components/Highlight/types.ts new file mode 100644 index 000000000..e9a6fe616 --- /dev/null +++ b/packages/components/src/components/Highlight/types.ts @@ -0,0 +1,22 @@ +export const highlightPropVariant = ['background', 'bold'] as const; + +export type HighlightPropVariant = (typeof highlightPropVariant)[number]; + +export type HighlightBaseProps = { + /** The text to search in and render. */ + text?: string; + /** + * The search query. Matching is case-insensitive and every occurrence is highlighted. + * When empty, the text is rendered without highlighting. + */ + query?: string; + /** + * The variant to use. + * @default 'background' + */ + variant?: HighlightPropVariant; + /** The component renders `text`, so it accepts no children. */ + children?: never; + /** Additional CSS-classes. */ + className?: string; +}; diff --git a/packages/components/src/components/Highlight/utils.ts b/packages/components/src/components/Highlight/utils.ts new file mode 100644 index 000000000..edaa334b0 --- /dev/null +++ b/packages/components/src/components/Highlight/utils.ts @@ -0,0 +1,34 @@ +import { isString } from '@koobiq/react-core'; + +/** A chunk of text produced by splitting a string on a search query. */ +export type HighlightPart = { + text: string; + isMatch: boolean; +}; + +const REGEXP_SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g; + +/** Escapes characters that have a special meaning in a regular expression. */ +export const escapeRegExp = (value: string): string => + value ? value.replace(REGEXP_SPECIAL_CHARS, '\\$&') : value; + +/** + * Splits `text` into alternating plain and matched chunks. + * Matching is case-insensitive and covers every occurrence of `query`. + */ +export const splitByQuery = ( + text: unknown, + query: unknown +): HighlightPart[] => { + if (!isString(text)) return []; + + if (!query || !isString(query)) return [{ text, isMatch: false }]; + + // The capture group makes `split` interleave plain and matched chunks, + // so odd indices are the matches. Empty chunks are dropped afterwards + // to keep the index parity intact. + return text + .split(new RegExp(`(${escapeRegExp(query)})`, 'gi')) + .map((part, index) => ({ text: part, isMatch: index % 2 === 1 })) + .filter((part) => part.text !== ''); +}; diff --git a/packages/components/src/components/Username/Username.mdx b/packages/components/src/components/Username/Username.mdx index 8e2f65c1e..77d91986c 100644 --- a/packages/components/src/components/Username/Username.mdx +++ b/packages/components/src/components/Username/Username.mdx @@ -197,4 +197,7 @@ buildUsernameText({ name, login, site }, { formatLogin: (l) => `[${l}]` }); // → "Root M. A. [mroot] (corp)" ``` +To mark the matched fragment in the result, render the custom view and wrap each +segment in [`Highlight`](/docs/components-highlight--docs). + diff --git a/packages/components/src/components/Username/Username.stories.tsx b/packages/components/src/components/Username/Username.stories.tsx index 36f5a7fa4..90dd41568 100644 --- a/packages/components/src/components/Username/Username.stories.tsx +++ b/packages/components/src/components/Username/Username.stories.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { FlexBox } from '../FlexBox'; +import { Highlight } from '../Highlight'; import { Link } from '../Link'; import { SearchInput } from '../SearchInput'; import { Typography } from '../Typography'; @@ -256,13 +257,32 @@ export const SearchAndHighlight: Story = { placeholder="Search users..." /> - {filtered.map((user) => ( - - ))} + {filtered.map((user) => { + const name = formatUsername(user, 'lf.m.'); + + const hint = user.site ? ( + + {' ('} + + {')'} + + ) : null; + + return ( + + + + {!name && hint} + + {name && user.login && ( + + + {hint} + + )} + + ); + })} ); diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 8af9f7841..f9077937c 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -65,6 +65,7 @@ export * from './FileUpload'; export * from './Username'; export * from './TopBar'; export * from './DropdownMenu'; +export * from './Highlight'; export * from './layout'; export { useListData, diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 9fb1d3681..035a2e3b4 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -28,6 +28,7 @@ "Form", "FormField", "Grid", + "Highlight", "IconButton", "IconItem", "Input", diff --git a/tools/public_api_guard/components/Highlight.api.md b/tools/public_api_guard/components/Highlight.api.md new file mode 100644 index 000000000..fb06581b3 --- /dev/null +++ b/tools/public_api_guard/components/Highlight.api.md @@ -0,0 +1,35 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ComponentPropsWithRef } from 'react'; +import type { ElementType } from 'react'; +import { PolyForwardComponent } from '@koobiq/react-core'; + +// @public +const Highlight_2: PolyForwardComponent<"span", HighlightBaseProps, ElementType>; +export { Highlight_2 as Highlight } + +// @public (undocumented) +export type HighlightBaseProps = { + text?: string; + query?: string; + variant?: HighlightPropVariant; + children?: never; + className?: string; +}; + +// @public (undocumented) +export type HighlightProps = ComponentPropsWithRef>; + +// @public (undocumented) +export type HighlightPropVariant = (typeof highlightPropVariant)[number]; + +// @public (undocumented) +export const highlightPropVariant: readonly ["background", "bold"]; + +// (No @packageDocumentation comment for this package) + +``` From 3fb67be277354b1e191d72760948d5b806e21c04 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 18 Aug 2026 12:49:46 +0300 Subject: [PATCH 2/5] fix(Highlight, Username): fix diacritic matching and story regressions (DS-4815) Applies code-review findings from the Highlight component: query matching now tolerates diacritics to stay consistent with useFilter's locale-aware contains/startsWith, render output is memoized with stable mark keys, and the bold variant sources its weight from a design token instead of a literal. Also fixes the SearchAndHighlight story's hand-rolled Primary/Secondary/hint composition, which had silently diverged from Username's own isCompact, fullNameFormat, and hint-placement behavior. --- .storybook/components/Roadmap/data.ts | 12 ++--- .../components/Highlight/Highlight.module.css | 2 +- .../components/Highlight/Highlight.test.tsx | 7 +++ .../src/components/Highlight/Highlight.tsx | 20 ++++--- .../src/components/Highlight/utils.ts | 53 +++++++++++++------ .../components/Username/Username.stories.tsx | 46 ++++++++++++---- .../src/components/Username/Username.tsx | 8 ++- .../src/components/Username/utils.ts | 3 ++ 8 files changed, 109 insertions(+), 42 deletions(-) diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index ee69b50be..62823e06a 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -410,6 +410,12 @@ export const rows: Rows = [ stage: '🔵 experimental', planned: 'Q3 2026', }, + { + component: 'Highlight', + status: '✅ Done', + stage: '🔵 experimental', + planned: 'Q3 2026', + }, { component: 'DropdownMenu', status: '✅ Done', @@ -443,10 +449,4 @@ export const rows: Rows = [ status: '🚧 Planned', planned: 'Q3 2026', }, - { - component: 'Highlight', - status: '✅ Done', - stage: '🔵 experimental', - planned: 'Q3 2026', - }, ]; diff --git a/packages/components/src/components/Highlight/Highlight.module.css b/packages/components/src/components/Highlight/Highlight.module.css index 7111cf25b..475231736 100644 --- a/packages/components/src/components/Highlight/Highlight.module.css +++ b/packages/components/src/components/Highlight/Highlight.module.css @@ -28,5 +28,5 @@ } .bold { - --highlight-font-weight: bold; + --highlight-font-weight: var(--kbq-typography-text-normal-strong-font-weight); } diff --git a/packages/components/src/components/Highlight/Highlight.test.tsx b/packages/components/src/components/Highlight/Highlight.test.tsx index 8365c7feb..276a96fb8 100644 --- a/packages/components/src/components/Highlight/Highlight.test.tsx +++ b/packages/components/src/components/Highlight/Highlight.test.tsx @@ -159,6 +159,13 @@ describe('Highlight', () => { expect(getMarkTexts()).toEqual(['вет']); expect(getRoot().textContent).toBe('Привет мир'); }); + + it('should match a diacritic in the text against a plain query letter', () => { + render(); + + expect(getMarkTexts()).toEqual(['é']); + expect(getRoot().textContent).toBe('IPSéc'); + }); }); describe('regular expression special characters in the query', () => { diff --git a/packages/components/src/components/Highlight/Highlight.tsx b/packages/components/src/components/Highlight/Highlight.tsx index 2f7923bf8..d7cfa9cd2 100644 --- a/packages/components/src/components/Highlight/Highlight.tsx +++ b/packages/components/src/components/Highlight/Highlight.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { ComponentPropsWithRef, ElementType } from 'react'; +import { useMemo, type ComponentPropsWithRef, type ElementType } from 'react'; import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; @@ -20,7 +20,9 @@ export const Highlight = polymorphicForwardRef<'span', HighlightBaseProps>( ...other } = props; - const parts = splitByQuery(text, query); + const parts = useMemo(() => splitByQuery(text, query), [text, query]); + + let offset = 0; return ( ( {...other} ref={ref} > - {parts.map((part, index) => - part.isMatch ? ( - + {parts.map((part) => { + const key = offset; + + offset += part.text.length; + + return part.isMatch ? ( + {part.text} ) : ( part.text - ) - )} + ); + })} ); } diff --git a/packages/components/src/components/Highlight/utils.ts b/packages/components/src/components/Highlight/utils.ts index edaa334b0..bb063cb19 100644 --- a/packages/components/src/components/Highlight/utils.ts +++ b/packages/components/src/components/Highlight/utils.ts @@ -1,34 +1,55 @@ -import { isString } from '@koobiq/react-core'; - /** A chunk of text produced by splitting a string on a search query. */ export type HighlightPart = { text: string; isMatch: boolean; }; -const REGEXP_SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g; +const REGEXP_SPECIAL_CHAR = /[.*+?^${}()|[\]\\]/; +const COMBINING_MARK = /\p{M}/u; -/** Escapes characters that have a special meaning in a regular expression. */ -export const escapeRegExp = (value: string): string => - value ? value.replace(REGEXP_SPECIAL_CHARS, '\\$&') : value; +/** + * Builds a pattern that matches `query` against `text` regardless of diacritics + * (e.g. query "e" matches "é"), so matching stays consistent with the + * diacritic-insensitive `contains`/`startsWith` matchers `useFilter` provides + * for filtering the same data. + */ +const buildMatchPattern = (query: string): string => + Array.from(query.normalize('NFD')) + .filter((char) => !COMBINING_MARK.test(char)) + .map( + (char) => `${REGEXP_SPECIAL_CHAR.test(char) ? `\\${char}` : char}\\p{M}*` + ) + .join(''); /** * Splits `text` into alternating plain and matched chunks. - * Matching is case-insensitive and covers every occurrence of `query`. + * Matching is case- and diacritic-insensitive and covers every occurrence of `query`. */ export const splitByQuery = ( - text: unknown, - query: unknown + text?: string, + query?: string ): HighlightPart[] => { - if (!isString(text)) return []; + if (!text) return []; - if (!query || !isString(query)) return [{ text, isMatch: false }]; + if (!query) return [{ text, isMatch: false }]; + + const pattern = buildMatchPattern(query); + + if (!pattern) return [{ text, isMatch: false }]; // The capture group makes `split` interleave plain and matched chunks, - // so odd indices are the matches. Empty chunks are dropped afterwards - // to keep the index parity intact. + // so odd indices are the matches; `text` is NFD-normalized first so the + // pattern's `\p{M}*` can absorb a matched letter's combining diacritics, + // then each chunk is normalized back to NFC so the rendered text is + // codepoint-identical to the original (composed) `text` prop. return text - .split(new RegExp(`(${escapeRegExp(query)})`, 'gi')) - .map((part, index) => ({ text: part, isMatch: index % 2 === 1 })) - .filter((part) => part.text !== ''); + .normalize('NFD') + .split(new RegExp(`(${pattern})`, 'giu')) + .reduce((parts, chunk, index) => { + if (chunk !== '') { + parts.push({ text: chunk.normalize('NFC'), isMatch: index % 2 === 1 }); + } + + return parts; + }, []); }; diff --git a/packages/components/src/components/Username/Username.stories.tsx b/packages/components/src/components/Username/Username.stories.tsx index 90dd41568..1a80f80a4 100644 --- a/packages/components/src/components/Username/Username.stories.tsx +++ b/packages/components/src/components/Username/Username.stories.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { isNotNil } from '@koobiq/react-core'; import type { Meta, StoryObj } from '@storybook/react'; import { FlexBox } from '../FlexBox'; @@ -14,6 +15,7 @@ import { type UsernameUserInfo, formatUsername, buildUsernameText, + usernameHintAffixes, usernamePropMode, usernamePropType, } from './index.js'; @@ -249,6 +251,12 @@ export const SearchAndHighlight: Story = { .includes(query.toLowerCase()); }); + const { + isCompact = false, + fullNameFormat = 'lf.m.', + formatter = formatUsername, + } = args; + return ( {filtered.map((user) => { - const name = formatUsername(user, 'lf.m.'); + // Mirrors Username's own primary/secondary/hint placement rules + // (see Username.tsx) so this custom view stays consistent with it. + const hasFullName = Boolean(user.firstName && user.lastName); + const name = hasFullName ? formatter(user, fullNameFormat) : ''; + const primaryText = hasFullName ? name : user.login; + + const secondaryText = + !isCompact && hasFullName ? user.login : undefined; + + const showSiteInSecondary = isNotNil(user.site && secondaryText); + + const primaryHoldsLogin = isCompact + ? isNotNil(primaryText) + : isNotNil(user.login) && !hasFullName; + + const showSiteInPrimary = + isNotNil(user.site) && !showSiteInSecondary && primaryHoldsLogin; const hint = user.site ? ( - {' ('} + {usernameHintAffixes.prefix} - {')'} + {usernameHintAffixes.suffix} ) : null; return ( - - - {!name && hint} - - {name && user.login && ( + {primaryText && ( + + + {showSiteInPrimary && hint} + + )} + {secondaryText && ( - - {hint} + + {showSiteInSecondary && hint} )} diff --git a/packages/components/src/components/Username/Username.tsx b/packages/components/src/components/Username/Username.tsx index 616c384ea..910adef39 100644 --- a/packages/components/src/components/Username/Username.tsx +++ b/packages/components/src/components/Username/Username.tsx @@ -13,7 +13,7 @@ import s from './Username.module.css'; import { UsernamePrimary } from './UsernamePrimary'; import { UsernameSecondary } from './UsernameSecondary'; import { UsernameSecondaryHint } from './UsernameSecondaryHint'; -import { formatUsername } from './utils'; +import { formatUsername, usernameHintAffixes } from './utils'; /** * Displays a user's name based on profile data. @@ -64,7 +64,11 @@ const UsernameComponent = forwardRef, UsernameBaseProps>( isNotNil(userInfo?.site) && !showSiteInSecondary && primaryHoldsLogin; const hint = userInfo?.site ? ( - ({userInfo.site}) + + {usernameHintAffixes.prefix} + {userInfo.site} + {usernameHintAffixes.suffix} + ) : null; return ( diff --git a/packages/components/src/components/Username/utils.ts b/packages/components/src/components/Username/utils.ts index 02674fa6f..0cdb81488 100644 --- a/packages/components/src/components/Username/utils.ts +++ b/packages/components/src/components/Username/utils.ts @@ -1,5 +1,8 @@ import type { UsernameUserInfo } from './types'; +/** Punctuation `Username`'s built-in hint uses to wrap `userInfo.site`. */ +export const usernameHintAffixes = { prefix: ' (', suffix: ')' } as const; + const legacyMapping: Record = { l: 'lastName', f: 'firstName', From b7b90f12665e84c411c6e56d18f981330b30fe17 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 18 Aug 2026 13:39:14 +0300 Subject: [PATCH 3/5] fix(Highlight, Username): set displayName and refresh the API report (DS-4815) Adds the missing displayName on the polymorphic Highlight component, matching Typography and Container, and regenerates the Username API Extractor report for the newly exported usernameHintAffixes. Splits the react type imports back onto their own line (as in ButtonGroup) so the generated Highlight report keeps its `import type` form. --- packages/components/src/components/Highlight/Highlight.tsx | 5 ++++- tools/public_api_guard/components/Username.api.md | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/components/src/components/Highlight/Highlight.tsx b/packages/components/src/components/Highlight/Highlight.tsx index d7cfa9cd2..dd856da32 100644 --- a/packages/components/src/components/Highlight/Highlight.tsx +++ b/packages/components/src/components/Highlight/Highlight.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useMemo, type ComponentPropsWithRef, type ElementType } from 'react'; +import { useMemo } from 'react'; +import type { ComponentPropsWithRef, ElementType } from 'react'; import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; @@ -49,5 +50,7 @@ export const Highlight = polymorphicForwardRef<'span', HighlightBaseProps>( } ); +Highlight.displayName = 'Highlight'; + export type HighlightProps = ComponentPropsWithRef>; diff --git a/tools/public_api_guard/components/Username.api.md b/tools/public_api_guard/components/Username.api.md index 0c177dd65..9aca029bd 100644 --- a/tools/public_api_guard/components/Username.api.md +++ b/tools/public_api_guard/components/Username.api.md @@ -53,6 +53,12 @@ export type UsernameFormatOptions = { join?: 'concat' | 'space'; }; +// @public +export const usernameHintAffixes: { + readonly prefix: " ("; + readonly suffix: ")"; +}; + // @public (undocumented) export type UsernamePrimaryProps = Omit, 'children'> & { children?: ReactNode; From 3c046df8709e163ec080c92b4fbc082ba5225009 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 18 Aug 2026 14:07:55 +0300 Subject: [PATCH 4/5] fix(Username): filter the search story by the text it renders (DS-4815) The SearchAndHighlight story filtered with a hard-coded formatUsername(user, 'lf.m.') while rendering with the formatter and fullNameFormat from args, so changing either control searched different text than it displayed. Compact mode had the same gap: the login was hidden but still matched. Derives the primary, secondary, and site values once and uses them for both filtering and rendering, so the two cannot drift apart. --- .../components/Username/Username.stories.tsx | 76 +++++++++++++------ 1 file changed, 51 insertions(+), 25 deletions(-) diff --git a/packages/components/src/components/Username/Username.stories.tsx b/packages/components/src/components/Username/Username.stories.tsx index 1a80f80a4..a09a71792 100644 --- a/packages/components/src/components/Username/Username.stories.tsx +++ b/packages/components/src/components/Username/Username.stories.tsx @@ -243,20 +243,57 @@ export const SearchAndHighlight: Story = { { login: 'ghost', site: 'external' }, ]; - const filtered = searchUsers.filter((user) => { - const name = formatUsername(user, 'lf.m.'); - - return buildUsernameText({ name, login: user.login, site: user.site }) - .toLowerCase() - .includes(query.toLowerCase()); - }); - const { isCompact = false, fullNameFormat = 'lf.m.', formatter = formatUsername, } = args; + // Mirrors Username's own primary/secondary/hint placement rules + // (see Username.tsx) so this custom view stays consistent with it. + const getDisplayParts = (user: UsernameUserInfo) => { + const hasFullName = Boolean(user.firstName && user.lastName); + const name = hasFullName ? formatter(user, fullNameFormat) : ''; + const primaryText = hasFullName ? name : user.login; + + const secondaryText = !isCompact && hasFullName ? user.login : undefined; + + const showSiteInSecondary = isNotNil(user.site && secondaryText); + + const primaryHoldsLogin = isCompact + ? isNotNil(primaryText) + : isNotNil(user.login) && !hasFullName; + + const showSiteInPrimary = + isNotNil(user.site) && !showSiteInSecondary && primaryHoldsLogin; + + return { + primaryText, + secondaryText, + showSiteInPrimary, + showSiteInSecondary, + }; + }; + + // Search the text the story actually renders, so the story controls keep + // filtering and highlighting in sync. + const filtered = searchUsers.filter((user) => { + const { + primaryText, + secondaryText, + showSiteInPrimary, + showSiteInSecondary, + } = getDisplayParts(user); + + return buildUsernameText({ + name: primaryText ?? '', + login: secondaryText, + site: showSiteInPrimary || showSiteInSecondary ? user.site : undefined, + }) + .toLowerCase() + .includes(query.toLowerCase()); + }); + return ( {filtered.map((user) => { - // Mirrors Username's own primary/secondary/hint placement rules - // (see Username.tsx) so this custom view stays consistent with it. - const hasFullName = Boolean(user.firstName && user.lastName); - const name = hasFullName ? formatter(user, fullNameFormat) : ''; - const primaryText = hasFullName ? name : user.login; - - const secondaryText = - !isCompact && hasFullName ? user.login : undefined; - - const showSiteInSecondary = isNotNil(user.site && secondaryText); - - const primaryHoldsLogin = isCompact - ? isNotNil(primaryText) - : isNotNil(user.login) && !hasFullName; - - const showSiteInPrimary = - isNotNil(user.site) && !showSiteInSecondary && primaryHoldsLogin; + const { + primaryText, + secondaryText, + showSiteInPrimary, + showSiteInSecondary, + } = getDisplayParts(user); const hint = user.site ? ( From 3925fd6d0cb448e341532ad41dd20f9b947423fe Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 25 Aug 2026 15:50:44 +0300 Subject: [PATCH 5/5] fix(Highlight): address review comments (DS-4815) --- .../components/Autocomplete/Autocomplete.mdx | 2 +- .../src/components/Highlight/Highlight.mdx | 4 +- .../Highlight/Highlight.stories.tsx | 57 ++++++++++++++----- .../src/components/Highlight/Highlight.tsx | 16 ++---- .../src/components/Username/Username.mdx | 2 +- 5 files changed, 50 insertions(+), 31 deletions(-) diff --git a/packages/components/src/components/Autocomplete/Autocomplete.mdx b/packages/components/src/components/Autocomplete/Autocomplete.mdx index 4e94412ed..1ecae4588 100644 --- a/packages/components/src/components/Autocomplete/Autocomplete.mdx +++ b/packages/components/src/components/Autocomplete/Autocomplete.mdx @@ -96,7 +96,7 @@ The following example uses the `defaultFilter` prop to filter the list of option ### Highlighting matches -Use [`Highlight`](/docs/components-highlight--docs) to mark the part of an option that matched the query. +Use [Highlight](/docs/components-highlight--docs) to mark the part of an option that matched the query. Control `inputValue` so the query is available for both filtering and highlighting, and set `textValue` on the item so the collection keeps a plain-text label for typeahead and accessibility. diff --git a/packages/components/src/components/Highlight/Highlight.mdx b/packages/components/src/components/Highlight/Highlight.mdx index 974cbf673..0b30b3735 100644 --- a/packages/components/src/components/Highlight/Highlight.mdx +++ b/packages/components/src/components/Highlight/Highlight.mdx @@ -58,9 +58,7 @@ The component renders an inline `` by default. Use the `as` prop when the highlighted text is a block of its own, or when it has to be a specific element such as a table cell. -```tsx - -``` + ## In text diff --git a/packages/components/src/components/Highlight/Highlight.stories.tsx b/packages/components/src/components/Highlight/Highlight.stories.tsx index 3e940a250..9be9c0b72 100644 --- a/packages/components/src/components/Highlight/Highlight.stories.tsx +++ b/packages/components/src/components/Highlight/Highlight.stories.tsx @@ -59,6 +59,34 @@ export const Variant: Story = { ), }; +export const RootTag: Story = { + render: (args) => ( + + + + A default {' '} + stays in the surrounding text flow. + + + as = span (default) + + + + + + as = p + + + + ), +}; + export const Text: Story = { render: (args) => { const query = 'cub'; @@ -100,21 +128,20 @@ export const InAutocomplete: Story = { const items = clubs.filter((club) => contains(club.name, inputValue)); return ( -
- - {(item) => ( - - - - )} - -
+ + {(item) => ( + + + + )} + ); }, }; diff --git a/packages/components/src/components/Highlight/Highlight.tsx b/packages/components/src/components/Highlight/Highlight.tsx index dd856da32..e27d201a8 100644 --- a/packages/components/src/components/Highlight/Highlight.tsx +++ b/packages/components/src/components/Highlight/Highlight.tsx @@ -23,8 +23,6 @@ export const Highlight = polymorphicForwardRef<'span', HighlightBaseProps>( const parts = useMemo(() => splitByQuery(text, query), [text, query]); - let offset = 0; - return ( ( {...other} ref={ref} > - {parts.map((part) => { - const key = offset; - - offset += part.text.length; - - return part.isMatch ? ( - + {parts.map((part, index) => + part.isMatch ? ( + {part.text} ) : ( part.text - ); - })} + ) + )} ); } diff --git a/packages/components/src/components/Username/Username.mdx b/packages/components/src/components/Username/Username.mdx index 77d91986c..9defa4609 100644 --- a/packages/components/src/components/Username/Username.mdx +++ b/packages/components/src/components/Username/Username.mdx @@ -198,6 +198,6 @@ buildUsernameText({ name, login, site }, { formatLogin: (l) => `[${l}]` }); ``` To mark the matched fragment in the result, render the custom view and wrap each -segment in [`Highlight`](/docs/components-highlight--docs). +segment in [Highlight](/docs/components-highlight--docs).