Skip to content
This repository has been archived by the owner on Jun 11, 2021. It is now read-only.

Commit

Permalink
feat(react): create highlighting components
Browse files Browse the repository at this point in the history
  • Loading branch information
francoischalifour committed Mar 10, 2020
1 parent c6c4da5 commit fb49161
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 109 deletions.
150 changes: 60 additions & 90 deletions packages/autocomplete-preset-algolia/src/formatting.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,21 @@
const htmlEscapes = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
type ParseAttributeParams = {
highlightPreTag: string;
highlightPostTag: string;
highlightedValue: string;
};

const unescapedHtml = /[&<>"']/g;
const hasUnescapedHtml = RegExp(unescapedHtml.source);
type ParsedAttribute = { value: string; isHighlighted: boolean };

function escape(value: string): string {
return value && hasUnescapedHtml.test(value)
? value.replace(unescapedHtml, char => htmlEscapes[char])
: value;
}

function parseHighlightedAttribute({
highlightPreTag,
highlightPostTag,
export function parseAttribute({
highlightPreTag = '<mark>',
highlightPostTag = '</mark>',
highlightedValue,
}) {
}: ParseAttributeParams): ParsedAttribute[] {
const splitByPreTag = highlightedValue.split(highlightPreTag);
const firstValue = splitByPreTag.shift();
const elements =
firstValue === '' ? [] : [{ value: firstValue, isHighlighted: false }];
const elements = !firstValue
? []
: [{ value: firstValue, isHighlighted: false }];

if (highlightPostTag === highlightPreTag) {
let isHighlighted = true;
Expand Down Expand Up @@ -53,105 +45,83 @@ function parseHighlightedAttribute({
return elements;
}

function getPropertyByPath(hit: object, path: string): string {
function getAttributeValueByPath(hit: object, path: string): string {
const parts = path.split('.');
const value = parts.reduce((current, key) => current && current[key], hit);

return typeof value === 'string' ? value : '';
if (typeof value !== 'string') {
throw new Error(
`The attribute ${JSON.stringify(path)} does not exist on the hit.`
);
}

return value;
}

interface HighlightOptions {
type SharedParseAttributeParams = {
hit: any;
attribute: string;
highlightPreTag?: string;
highlightPostTag?: string;
ignoreEscape?: string[];
}

interface GetHighlightedValue extends HighlightOptions {
hitKey: '_highlightResult' | '_snippetResult';
}
highlightPreTag: string;
highlightPostTag: string;
};

function getHighlightedValue({
export function parseHighlightedAttribute({
hit,
hitKey,
attribute,
highlightPreTag = '<mark>',
highlightPostTag = '</mark>',
ignoreEscape = [],
}: GetHighlightedValue): string {
const highlightedValue = getPropertyByPath(
highlightPreTag,
highlightPostTag,
}: SharedParseAttributeParams): ParsedAttribute[] {
const highlightedValue = getAttributeValueByPath(
hit,
`${hitKey}.${attribute}.value`
`_highlightResult.${attribute}.value`
);

return parseHighlightedAttribute({
return parseAttribute({
highlightPreTag,
highlightPostTag,
highlightedValue,
})
.map(part => {
const escapedValue =
ignoreEscape.indexOf(part.value) === -1
? part.value
: escape(part.value);

return part.isHighlighted
? `${highlightPreTag}${escapedValue}${highlightPostTag}`
: escapedValue;
})
.join('');
}

export function highlightAlgoliaHit(options: HighlightOptions): string {
return getHighlightedValue({
hitKey: '_highlightResult',
...options,
});
}

export function snippetAlgoliaHit(options: HighlightOptions): string {
return getHighlightedValue({
hitKey: '_snippetResult',
...options,
});
}

export function reverseHighlightAlgoliaHit({
export function parseReverseHighlightedAttribute({
hit,
attribute,
highlightPreTag = '<mark>',
highlightPostTag = '</mark>',
ignoreEscape = [],
}: HighlightOptions): string {
const highlightedValue = getPropertyByPath(
highlightPreTag,
highlightPostTag,
}: SharedParseAttributeParams): ParsedAttribute[] {
const highlightedValue = getAttributeValueByPath(
hit,
`_highlightResult.${attribute}.value`
);
const parsedHighlightedAttribute = parseHighlightedAttribute({

const parts = parseAttribute({
highlightPreTag,
highlightPostTag,
highlightedValue,
});
const noPartsMatch = !parsedHighlightedAttribute.some(
part => part.isHighlighted
);

return parsedHighlightedAttribute
.map(part => {
const escapedValue =
ignoreEscape.indexOf(part.value) === -1
? part.value
: escape(part.value);
// We don't want to highlight the whole word when no parts match.
if (!parts.some(part => part.isHighlighted)) {
return parts.map(part => ({ ...part, isHighlighted: false }));
}

// We don't want to highlight the whole word when no parts match.
if (noPartsMatch) {
return escapedValue;
}
return parts.map(part => ({ ...part, isHighlighted: !part.isHighlighted }));
}

return part.isHighlighted
? escapedValue
: `${highlightPreTag}${escapedValue}${highlightPostTag}`;
})
.join('');
export function parseSnippetedAttribute({
hit,
attribute,
highlightPreTag,
highlightPostTag,
}: SharedParseAttributeParams): ParsedAttribute[] {
const highlightedValue = getAttributeValueByPath(
hit,
`_snippetResult.${attribute}.value`
);

return parseAttribute({
highlightPreTag,
highlightPostTag,
highlightedValue,
});
}
6 changes: 3 additions & 3 deletions packages/autocomplete-preset-algolia/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { getAlgoliaHits, getAlgoliaResults } from './results';
export {
highlightAlgoliaHit,
reverseHighlightAlgoliaHit,
snippetAlgoliaHit,
parseHighlightedAttribute,
parseReverseHighlightedAttribute,
parseSnippetedAttribute,
} from './formatting';
13 changes: 3 additions & 10 deletions packages/autocomplete-react/src/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React from 'react';

import { reverseHighlightAlgoliaHit } from '@francoischalifour/autocomplete-preset-algolia';

import {
AutocompleteState,
GetDropdownProps,
GetMenuProps,
GetItemProps,
} from '@francoischalifour/autocomplete-core';

import { ReverseHighlight } from './Highlight';

interface DropdownProps {
isOpen: boolean;
status: string;
Expand Down Expand Up @@ -55,14 +55,7 @@ export const Dropdown = (props: DropdownProps) => {
source,
})}
>
<div
dangerouslySetInnerHTML={{
__html: reverseHighlightAlgoliaHit({
hit: item,
attribute: 'query',
}),
}}
/>
<ReverseHighlight hit={item} attribute="query" />
</li>
);
})}
Expand Down
102 changes: 102 additions & 0 deletions packages/autocomplete-react/src/Highlight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React from 'react';
import {
parseHighlightedAttribute,
parseReverseHighlightedAttribute,
parseSnippetedAttribute,
} from '@francoischalifour/autocomplete-preset-algolia';

type HighlighterProps = {
[prop: string]: unknown;
tagName: string;
parts: ReturnType<typeof parseHighlightedAttribute>;
};

function Highlighter({ tagName, parts, ...rest }: HighlighterProps) {
return (
<span {...rest}>
{parts.map((part, index) => {
if (part.isHighlighted) {
return React.createElement(tagName, { key: index }, part.value);
}

return part.value;
})}
</span>
);
}

type HighlightProps = {
[prop: string]: unknown;
hit: any;
attribute: string;
tagName?: string;
};

export function Highlight({
hit,
attribute,
tagName = 'mark',
...rest
}: HighlightProps) {
let parts: ReturnType<typeof parseHighlightedAttribute> = [];

try {
parts = parseHighlightedAttribute({
hit,
attribute,
highlightPreTag: `<${tagName}>`,
highlightPostTag: `</${tagName}>`,
});
} catch (error) {
// eslint-disable-next-line no-console
console.warn(error.message);
}

return <Highlighter tagName={tagName} parts={parts} {...rest} />;
}

export function Snippet({
hit,
attribute,
tagName = 'mark',
...rest
}: HighlightProps) {
let parts: ReturnType<typeof parseSnippetedAttribute> = [];

try {
parts = parseSnippetedAttribute({
hit,
attribute,
highlightPreTag: `<${tagName}>`,
highlightPostTag: `</${tagName}>`,
});
} catch (error) {
// eslint-disable-next-line no-console
console.warn(error.message);
}

return <Highlighter tagName={tagName} parts={parts} {...rest} />;
}

export function ReverseHighlight({
hit,
attribute,
tagName = 'mark',
...rest
}: HighlightProps) {
let parts: ReturnType<typeof parseReverseHighlightedAttribute> = [];

try {
parts = parseReverseHighlightedAttribute({
hit,
attribute,
highlightPreTag: `<${tagName}>`,
highlightPostTag: `</${tagName}>`,
});
} catch (error) {
// eslint-disable-next-line no-console
console.warn(error.message);
}

return <Highlighter tagName={tagName} parts={parts} {...rest} />;
}
7 changes: 1 addition & 6 deletions packages/autocomplete-react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
export { Autocomplete } from './Autocomplete';
export { Highlight, ReverseHighlight, Snippet } from './Highlight';
export { getAlgoliaHits } from './getAlgoliaHits';
export { getAlgoliaResults } from './getAlgoliaResults';
// @TODO: provide React helpers without relying on `innerHTML`.
export {
highlightAlgoliaHit,
reverseHighlightAlgoliaHit,
snippetAlgoliaHit,
} from '@francoischalifour/autocomplete-preset-algolia';

0 comments on commit fb49161

Please sign in to comment.