diff --git a/.circleci/config.yml b/.circleci/config.yml index a1b4e14c1..a51f45302 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,6 +30,7 @@ aliases: mkdir -p packages/autocomplete-plugin-algolia-insights/dist mkdir -p packages/autocomplete-plugin-recent-searches/dist mkdir -p packages/autocomplete-plugin-query-suggestions/dist + mkdir -p packages/autocomplete-plugin-tags/dist cp -R /tmp/workspace/packages/autocomplete-shared/dist packages/autocomplete-shared cp -R /tmp/workspace/packages/autocomplete-core/dist packages/autocomplete-core @@ -38,6 +39,7 @@ aliases: cp -R /tmp/workspace/packages/autocomplete-plugin-algolia-insights/dist packages/autocomplete-plugin-algolia-insights cp -R /tmp/workspace/packages/autocomplete-plugin-recent-searches/dist packages/autocomplete-plugin-recent-searches cp -R /tmp/workspace/packages/autocomplete-plugin-query-suggestions/dist packages/autocomplete-plugin-query-suggestions + cp -R /tmp/workspace/packages/autocomplete-plugin-tags/dist packages/autocomplete-plugin-tags defaults: &defaults working_directory: ~/autocomplete @@ -82,6 +84,7 @@ jobs: mkdir -p /tmp/workspace/packages/autocomplete-plugin-algolia-insights/dist mkdir -p /tmp/workspace/packages/autocomplete-plugin-recent-searches/dist mkdir -p /tmp/workspace/packages/autocomplete-plugin-query-suggestions/dist + mkdir -p /tmp/workspace/packages/autocomplete-plugin-tags/dist cp -R packages/autocomplete-shared/dist /tmp/workspace/packages/autocomplete-shared cp -R packages/autocomplete-core/dist /tmp/workspace/packages/autocomplete-core @@ -90,6 +93,7 @@ jobs: cp -R packages/autocomplete-plugin-algolia-insights/dist /tmp/workspace/packages/autocomplete-plugin-algolia-insights cp -R packages/autocomplete-plugin-recent-searches/dist /tmp/workspace/packages/autocomplete-plugin-recent-searches cp -R packages/autocomplete-plugin-query-suggestions/dist /tmp/workspace/packages/autocomplete-plugin-query-suggestions + cp -R packages/autocomplete-plugin-tags/dist /tmp/workspace/packages/autocomplete-plugin-tags - persist_to_workspace: root: *workspace_root paths: diff --git a/.stylelintrc.json b/.stylelintrc.json index c1461ce35..8620b6e62 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -14,7 +14,7 @@ "order/properties-alphabetical-order": true, "no-descending-specificity": null, "selector-class-pattern": [ - "^aa-(?:[A-Z][a-z]+)+(?:--[a-z]+(?:[A-Z][a-z]+)?)?$" + "^aa(-(?:[A-Z][a-z]+Plugin))?-(?:[A-Z][a-z]+)+(?:--[a-z]+(?:[A-Z][a-z]+)?)?$" ], "prettier/prettier": true, "max-nesting-depth": null, diff --git a/README.md b/README.md index a2d4af9ac..b95bbb2c4 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ You can find more on the [documentation](https://www.algolia.com/doc/ui-librarie | [`autocomplete-plugin-recent-searches`](packages/autocomplete-plugin-recent-searches) | A plugin to add recent searches to Autocomplete | [Documentation](https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-recent-searches) | | [`autocomplete-plugin-query-suggestions`](packages/autocomplete-plugin-query-suggestions) | A plugin to add query suggestions to Autocomplete | [Documentation](https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-query-suggestions) | | [`autocomplete-plugin-algolia-insights`](packages/autocomplete-plugin-algolia-insights) | A plugin to add Algolia Insights to Autocomplete | [Documentation](https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-algolia-insights) | +| [`autocomplete-plugin-tags`](packages/autocomplete-plugin-tags) | A plugin to manage and display a list of tags in Autocomplete | [Documentation](https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-tags) | | [`autocomplete-preset-algolia`](packages/autocomplete-preset-algolia) | Presets to use Algolia features with Autocomplete | [Documentation](https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-preset-algolia) | | [`autocomplete-theme-classic`](packages/autocomplete-theme-classic) | Classic theme for Autocomplete | [Documentation](https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-theme-classic) | diff --git a/bundlesize.config.json b/bundlesize.config.json index a4a3a54cb..556e71985 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -24,6 +24,10 @@ "path": "packages/autocomplete-plugin-query-suggestions/dist/umd/index.production.js", "maxSize": "4 kB" }, + { + "path": "packages/autocomplete-plugin-tags/dist/umd/index.production.js", + "maxSize": "2.25 kB" + }, { "path": "packages/autocomplete-theme-classic/dist/theme.min.css", "maxSize": "4.25 kB" diff --git a/examples/tags-in-searchbox/README.md b/examples/tags-in-searchbox/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tags-in-searchbox/app.tsx b/examples/tags-in-searchbox/app.tsx new file mode 100644 index 000000000..c604dad11 --- /dev/null +++ b/examples/tags-in-searchbox/app.tsx @@ -0,0 +1,368 @@ +/** @jsx h */ +import { + autocomplete, + AutocompleteComponents, + getAlgoliaResults, + getAlgoliaFacets, +} from '@algolia/autocomplete-js'; +import { + AutocompleteInsightsApi, + createAlgoliaInsightsPlugin, +} from '@algolia/autocomplete-plugin-algolia-insights'; +import { createTagsPlugin, Tag } from '@algolia/autocomplete-plugin-tags'; +import algoliasearch from 'algoliasearch'; +import { h, Fragment, render } from 'preact'; +import groupBy from 'ramda/src/groupBy'; +import insightsClient from 'search-insights'; + +import '@algolia/autocomplete-theme-classic'; + +import { ProductHit, TagExtraData } from './types'; + +const appId = 'latency'; +const apiKey = '6be0576ff61c053d5f9a3225e2a90f76'; +const searchClient = algoliasearch(appId, apiKey); + +// @ts-expect-error type error in search-insights +insightsClient('init', { appId, apiKey }); + +const algoliaInsightsPlugin = createAlgoliaInsightsPlugin({ insightsClient }); + +const tagsPlugin = createTagsPlugin({ + getTagsSubscribers() { + return [ + { + sourceId: 'brands', + getTag({ item }) { + return item; + }, + }, + { + sourceId: 'categories', + getTag({ item }) { + return item; + }, + }, + ]; + }, + transformSource() { + return undefined; + }, + onChange({ tags }) { + requestAnimationFrame(() => { + const container = document.querySelector('.aa-InputWrapperPrefix'); + const oldTagsContainer = document.querySelector('.aa-Tags'); + + const tagsContainer = document.createElement('div'); + tagsContainer.classList.add('aa-Tags'); + + render( +
+ {tags.map((tag) => ( + + ))} +
, + tagsContainer + ); + + if (oldTagsContainer) { + container.replaceChild(tagsContainer, oldTagsContainer); + } else { + container.appendChild(tagsContainer); + } + }); + }, +}); + +type TagItemProps = Tag; + +function TagItem({ label, remove }: TagItemProps) { + return ( +
+ {label} + +
+ ); +} + +autocomplete>({ + container: '#autocomplete', + placeholder: 'Search', + openOnFocus: true, + plugins: [algoliaInsightsPlugin, tagsPlugin], + detachedMediaQuery: 'none', + getSources({ query, state }) { + const tagsByFacet = groupBy>( + (tag) => tag.facet, + state.context.tagsPlugin.tags + ); + + return [ + { + sourceId: 'brands', + onSelect({ item, state, setQuery }) { + if ( + item.label.toLowerCase().includes(state.query.toLowerCase().trim()) + ) { + setQuery(''); + } + }, + getItems({ query }) { + return getAlgoliaFacets({ + searchClient, + queries: [ + { + indexName: 'instant_search', + facet: 'brand', + params: { + facetQuery: query, + maxFacetHits: 3, + filters: mapToAlgoliaNegativeFilters( + state.context.tagsPlugin.tags, + ['brand'] + ), + }, + }, + ], + transformResponse({ facetHits }) { + return facetHits[0].map((hit) => ({ ...hit, facet: 'brand' })); + }, + }); + }, + templates: { + header() { + return ( + + Brands +
+ + ); + }, + item({ item, components }) { + return ( +
+
+
+
+ Filter on{' '} + +
+
+
+
+ +
+
+ ); + }, + noResults() { + return 'No brands for this query.'; + }, + }, + }, + { + sourceId: 'products', + getItems() { + return getAlgoliaResults({ + searchClient, + queries: [ + { + indexName: 'instant_search', + query, + params: { + clickAnalytics: true, + attributesToSnippet: ['name:10'], + snippetEllipsisText: '…', + filters: mapToAlgoliaFilters(tagsByFacet), + }, + }, + ], + }); + }, + templates: { + header() { + return ( + + Products +
+ + ); + }, + item({ item, components }) { + return ( + + ); + }, + noResults() { + return 'No products for this query.'; + }, + }, + }, + ]; + }, +}); + +type ProductItemProps = { + hit: ProductHit; + insights: AutocompleteInsightsApi; + components: AutocompleteComponents; +}; + +function ProductItem({ hit, insights, components }: ProductItemProps) { + return ( + +
+
+ {hit.name} +
+
+
+ +
+
+ From {hit.brand} in{' '} + {hit.categories[0]} +
+ {hit.rating > 0 && ( +
+
+ {Array.from({ length: 5 }, (_value, index) => { + const isFilled = hit.rating >= index + 1; + + return ( + + + + ); + })} +
+
+ )} +
+ ${hit.price.toLocaleString()} +
+
+
+
+ + +
+
+ ); +} + +const searchInput: HTMLInputElement = document.querySelector( + '.aa-Autocomplete .aa-Input' +); + +searchInput.addEventListener('keydown', (event) => { + if ( + event.key === 'Backspace' && + searchInput.selectionStart === 0 && + searchInput.selectionEnd === 0 + ) { + const newTags = tagsPlugin.data.tags.slice(0, -1); + tagsPlugin.data.setTags(newTags); + } +}); + +function mapToAlgoliaFilters( + tagsByFacet: Record>>, + operator = 'AND' +) { + return Object.keys(tagsByFacet) + .map((facet) => { + return `(${tagsByFacet[facet] + .map(({ label }) => `${facet}:"${label}"`) + .join(' OR ')})`; + }) + .join(` ${operator} `); +} + +function mapToAlgoliaNegativeFilters( + tags: Array>, + facetsToNegate: string[], + operator = 'AND' +) { + return tags + .map(({ label, facet }) => { + const filter = `${facet}:"${label}"`; + + return facetsToNegate.includes(facet) && `NOT ${filter}`; + }) + .filter(Boolean) + .join(` ${operator} `); +} diff --git a/examples/tags-in-searchbox/env.ts b/examples/tags-in-searchbox/env.ts new file mode 100644 index 000000000..6eef24529 --- /dev/null +++ b/examples/tags-in-searchbox/env.ts @@ -0,0 +1,10 @@ +import * as preact from 'preact'; + +// Parcel picks the `source` field of the monorepo packages and thus doesn't +// apply the Babel config. We therefore need to manually override the constants +// in the app, as well as the React pragmas. +// See https://twitter.com/devongovett/status/1134231234605830144 +(global as any).__DEV__ = process.env.NODE_ENV !== 'production'; +(global as any).__TEST__ = false; +(global as any).h = preact.h; +(global as any).React = preact; diff --git a/examples/tags-in-searchbox/favicon.png b/examples/tags-in-searchbox/favicon.png new file mode 100644 index 000000000..084fdfdfc Binary files /dev/null and b/examples/tags-in-searchbox/favicon.png differ diff --git a/examples/tags-in-searchbox/index.html b/examples/tags-in-searchbox/index.html new file mode 100644 index 000000000..053f635ed --- /dev/null +++ b/examples/tags-in-searchbox/index.html @@ -0,0 +1,20 @@ + + + + + + + + + Tags in the searchbox | Autocomplete + + + +
+
+
+ + + + + diff --git a/examples/tags-in-searchbox/package.json b/examples/tags-in-searchbox/package.json new file mode 100644 index 000000000..b140ec67c --- /dev/null +++ b/examples/tags-in-searchbox/package.json @@ -0,0 +1,30 @@ +{ + "name": "@algolia/autocomplete-example-tags-in-searchbox", + "description": "Autocomplete example with Tags in the searchbox", + "version": "1.3.0", + "private": true, + "license": "MIT", + "scripts": { + "build": "parcel build index.html", + "start": "parcel index.html" + }, + "dependencies": { + "@algolia/autocomplete-js": "1.3.0", + "@algolia/autocomplete-plugin-algolia-insights": "1.3.0", + "@algolia/autocomplete-plugin-tags": "1.3.0", + "@algolia/autocomplete-theme-classic": "1.3.0", + "@algolia/client-search": "4.9.1", + "algoliasearch": "4.9.1", + "preact": "10.5.13", + "ramda": "0.27.1", + "search-insights": "1.7.1" + }, + "devDependencies": { + "parcel": "2.0.0-beta.2" + }, + "keywords": [ + "algolia", + "autocomplete", + "javascript" + ] +} diff --git a/examples/tags-in-searchbox/style.css b/examples/tags-in-searchbox/style.css new file mode 100644 index 000000000..4d01551be --- /dev/null +++ b/examples/tags-in-searchbox/style.css @@ -0,0 +1,81 @@ +* { + box-sizing: border-box; +} + +body { + background-color: rgb(244, 244, 249); + color: rgb(65, 65, 65); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + padding: 1rem; +} + +.container { + margin: 0 auto; + max-width: 640px; + width: 100%; +} + +.aa-Tags { + margin-right: 8px; +} + +.aa-Tags:empty { + display: none; +} + +.aa-TagsList { + display: flex; + margin: 0 calc((var(--aa-spacing) / 5) * -1); +} + +.aa-Tag { + background-color: rgba( + var(--aa-selected-color-rgb), + var(--aa-selected-color-alpha) + ); + align-items: center; + display: flex; + margin: 0 calc(var(--aa-spacing) / 5); + padding-left: var(--aa-spacing-half); + border-radius: calc(var(--aa-spacing) / 4); +} + +.aa-Tag:hover, +.aa-Tag:focus { + background-color: rgba(var(--aa-selected-color-rgb), 0.55); +} + +.aa-TagLabel { + font-size: 0.8em; +} + +.aa-TagRemoveButton { + cursor: pointer; + padding: 0; + border: 0; + background: none; +} + +.aa-TagRemoveButton svg { + color: rgba(var(--aa-muted-color-rgb), var(--aa-muted-color-alpha)); + margin: 0; + margin: calc(var(--aa-spacing) / 2.5); + stroke-width: var(--aa-icon-stroke-width); + width: calc(var(--aa-action-icon-size) / 1.5); +} + +.aa-TagRemoveButton:hover svg, +.aa-TagRemoveButton:focus svg { + color: rgba(var(--aa-text-color-rgb), var(--aa-text-color-alpha)); +} + +@media (hover: none) and (pointer: coarse) { + .aa-TagRemoveButton:hover, + .aa-TagRemoveButton:focus { + color: inherit; + } +} diff --git a/examples/tags-in-searchbox/types/ProductHit.ts b/examples/tags-in-searchbox/types/ProductHit.ts new file mode 100644 index 000000000..349301e3c --- /dev/null +++ b/examples/tags-in-searchbox/types/ProductHit.ts @@ -0,0 +1,19 @@ +import { Hit } from '@algolia/client-search'; + +type ProductRecord = { + brand: string; + categories: string[]; + description: string; + image: string; + name: string; + price: number; + rating: number; + url: string; +}; + +type WithAutocompleteAnalytics = THit & { + __autocomplete_indexName: string; + __autocomplete_queryID: string; +}; + +export type ProductHit = WithAutocompleteAnalytics>; diff --git a/examples/tags-in-searchbox/types/TagExtraData.ts b/examples/tags-in-searchbox/types/TagExtraData.ts new file mode 100644 index 000000000..769e47dd9 --- /dev/null +++ b/examples/tags-in-searchbox/types/TagExtraData.ts @@ -0,0 +1 @@ +export type TagExtraData = { facet: string }; diff --git a/examples/tags-in-searchbox/types/index.ts b/examples/tags-in-searchbox/types/index.ts new file mode 100644 index 000000000..1344ae054 --- /dev/null +++ b/examples/tags-in-searchbox/types/index.ts @@ -0,0 +1,2 @@ +export * from './ProductHit'; +export * from './TagExtraData'; diff --git a/examples/tags-with-hits/README.md b/examples/tags-with-hits/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tags-with-hits/app.tsx b/examples/tags-with-hits/app.tsx new file mode 100644 index 000000000..6d198def0 --- /dev/null +++ b/examples/tags-with-hits/app.tsx @@ -0,0 +1,325 @@ +/** @jsx h */ +import { + autocomplete, + AutocompleteComponents, + getAlgoliaResults, + getAlgoliaFacets, +} from '@algolia/autocomplete-js'; +import { + AutocompleteInsightsApi, + createAlgoliaInsightsPlugin, +} from '@algolia/autocomplete-plugin-algolia-insights'; +import { createTagsPlugin, Tag } from '@algolia/autocomplete-plugin-tags'; +import algoliasearch from 'algoliasearch'; +import { h, Fragment } from 'preact'; +import groupBy from 'ramda/src/groupBy'; +import insightsClient from 'search-insights'; + +import '@algolia/autocomplete-theme-classic'; +import '@algolia/autocomplete-plugin-tags/dist/theme.min.css'; + +import { ProductHit, TagExtraData } from './types'; + +const appId = 'latency'; +const apiKey = '6be0576ff61c053d5f9a3225e2a90f76'; +const searchClient = algoliasearch(appId, apiKey); + +// @ts-expect-error type error in search-insights +insightsClient('init', { appId, apiKey }); + +const algoliaInsightsPlugin = createAlgoliaInsightsPlugin({ insightsClient }); + +const tagsPlugin = createTagsPlugin({ + getTagsSubscribers() { + return [ + { + sourceId: 'brands', + getTag({ item }) { + return item; + }, + }, + { + sourceId: 'categories', + getTag({ item }) { + return item; + }, + }, + ]; + }, +}); + +const categoriesSelect = document.getElementById( + 'categories' +) as HTMLSelectElement; + +autocomplete>({ + container: '#autocomplete', + placeholder: 'Search', + openOnFocus: true, + plugins: [algoliaInsightsPlugin, tagsPlugin], + detachedMediaQuery: 'none', + onStateChange({ state }) { + const tags = state.context.tagsPlugin?.tags || []; + const currentCategory = tags.find(({ facet }) => facet === 'categories'); + + categoriesSelect.value = currentCategory?.label || ''; + }, + getSources({ query, state }) { + const tagsByFacet = groupBy>( + (tag) => tag.facet, + state.context.tagsPlugin.tags + ); + + return [ + { + sourceId: 'brands', + onSelect({ item, state, setQuery }) { + if (item.label.toLowerCase().includes(state.query.toLowerCase())) { + setQuery(''); + } + }, + getItems({ query }) { + return getAlgoliaFacets({ + searchClient, + queries: [ + { + indexName: 'instant_search', + facet: 'brand', + params: { + facetQuery: query, + maxFacetHits: 3, + filters: mapToAlgoliaNegativeFilters( + state.context.tagsPlugin.tags, + ['brand'] + ), + }, + }, + ], + transformResponse({ facetHits }) { + return facetHits[0].map((hit) => ({ ...hit, facet: 'brand' })); + }, + }); + }, + templates: { + header() { + return ( + + Brands +
+ + ); + }, + item({ item, components }) { + return ( +
+
+
+
+ Filter on{' '} + +
+
+
+
+ +
+
+ ); + }, + noResults() { + return 'No brands for this query.'; + }, + }, + }, + { + sourceId: 'products', + getItems() { + return getAlgoliaResults({ + searchClient, + queries: [ + { + indexName: 'instant_search', + query, + params: { + clickAnalytics: true, + attributesToSnippet: ['name:10'], + snippetEllipsisText: '…', + filters: mapToAlgoliaFilters(tagsByFacet), + }, + }, + ], + }); + }, + templates: { + header() { + return ( + + Products +
+ + ); + }, + item({ item, components }) { + return ( + + ); + }, + noResults() { + return 'No products for this query.'; + }, + }, + }, + ]; + }, +}); + +type ProductItemProps = { + hit: ProductHit; + insights: AutocompleteInsightsApi; + components: AutocompleteComponents; +}; + +function ProductItem({ hit, insights, components }: ProductItemProps) { + return ( + +
+
+ {hit.name} +
+
+
+ +
+
+ From {hit.brand} in{' '} + {hit.categories[0]} +
+ {hit.rating > 0 && ( +
+
+ {Array.from({ length: 5 }, (_value, index) => { + const isFilled = hit.rating >= index + 1; + + return ( + + + + ); + })} +
+
+ )} +
+ ${hit.price.toLocaleString()} +
+
+
+
+ + +
+
+ ); +} + +function mapToAlgoliaFilters( + tagsByFacet: Record>>, + operator = 'AND' +) { + return Object.keys(tagsByFacet) + .map((facet) => { + return `(${tagsByFacet[facet] + .map(({ label }) => `${facet}:"${label}"`) + .join(' OR ')})`; + }) + .join(` ${operator} `); +} + +function mapToAlgoliaNegativeFilters( + tags: Array>, + facetsToNegate: string[], + operator = 'AND' +) { + return tags + .map(({ label, facet }) => { + const filter = `${facet}:"${label}"`; + + return facetsToNegate.includes(facet) && `NOT ${filter}`; + }) + .filter(Boolean) + .join(` ${operator} `); +} + +categoriesSelect.addEventListener('change', (event) => { + const value = (event.target as HTMLSelectElement).value; + const tags = tagsPlugin.data.tags.filter((tag) => tag.facet !== 'categories'); + + if (value) { + tagsPlugin.data.setTags([ + ...tags, + { + label: value, + facet: 'categories', + }, + ]); + } else { + tagsPlugin.data.setTags(tags); + } +}); diff --git a/examples/tags-with-hits/env.ts b/examples/tags-with-hits/env.ts new file mode 100644 index 000000000..6eef24529 --- /dev/null +++ b/examples/tags-with-hits/env.ts @@ -0,0 +1,10 @@ +import * as preact from 'preact'; + +// Parcel picks the `source` field of the monorepo packages and thus doesn't +// apply the Babel config. We therefore need to manually override the constants +// in the app, as well as the React pragmas. +// See https://twitter.com/devongovett/status/1134231234605830144 +(global as any).__DEV__ = process.env.NODE_ENV !== 'production'; +(global as any).__TEST__ = false; +(global as any).h = preact.h; +(global as any).React = preact; diff --git a/examples/tags-with-hits/favicon.png b/examples/tags-with-hits/favicon.png new file mode 100644 index 000000000..084fdfdfc Binary files /dev/null and b/examples/tags-with-hits/favicon.png differ diff --git a/examples/tags-with-hits/index.html b/examples/tags-with-hits/index.html new file mode 100644 index 000000000..03be0de35 --- /dev/null +++ b/examples/tags-with-hits/index.html @@ -0,0 +1,196 @@ + + + + + + + + + Tags with hits | Autocomplete + + + +
+
+ +
+
+
+ + + + + diff --git a/examples/tags-with-hits/package.json b/examples/tags-with-hits/package.json new file mode 100644 index 000000000..fbee343c1 --- /dev/null +++ b/examples/tags-with-hits/package.json @@ -0,0 +1,30 @@ +{ + "name": "@algolia/autocomplete-example-tags-with-hits", + "description": "Autocomplete example with Tags and hits", + "version": "1.3.0", + "private": true, + "license": "MIT", + "scripts": { + "build": "parcel build index.html", + "start": "parcel index.html" + }, + "dependencies": { + "@algolia/autocomplete-js": "1.3.0", + "@algolia/autocomplete-plugin-algolia-insights": "1.3.0", + "@algolia/autocomplete-plugin-tags": "1.3.0", + "@algolia/autocomplete-theme-classic": "1.3.0", + "@algolia/client-search": "4.9.1", + "algoliasearch": "4.9.1", + "preact": "10.5.13", + "ramda": "0.27.1", + "search-insights": "1.7.1" + }, + "devDependencies": { + "parcel": "2.0.0-beta.2" + }, + "keywords": [ + "algolia", + "autocomplete", + "javascript" + ] +} diff --git a/examples/tags-with-hits/style.css b/examples/tags-with-hits/style.css new file mode 100644 index 000000000..24bdf8b45 --- /dev/null +++ b/examples/tags-with-hits/style.css @@ -0,0 +1,62 @@ +* { + box-sizing: border-box; +} + +body { + background-color: rgb(244, 244, 249); + color: rgb(65, 65, 65); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + padding: 1rem; +} + +.container { + margin: 0 auto; + max-width: 640px; + width: 100%; +} + +#autocomplete { + flex-shrink: 0; + flex-grow: 1; +} + +.aa-SearchBoxWrapper { + display: flex; +} + +.aa-SearchBoxWrapper .aa-Select { + flex-grow: 0; + width: 200px; + padding: 0 calc(var(--aa-spacing) * 0.75 - 1px); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + cursor: pointer; +} + +.aa-Autocomplete .aa-Form { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 0; +} + +.aa-Select .aa-InputWrapper { + display: flex; +} + +.aa-Select select { + cursor: pointer; + text-overflow: ellipsis; +} + +.aa-SelectIcon { + order: 4; +} + +.aa-SelectIcon svg { + width: var(--aa-spacing); + height: var(--aa-spacing); +} diff --git a/examples/tags-with-hits/types/ProductHit.ts b/examples/tags-with-hits/types/ProductHit.ts new file mode 100644 index 000000000..349301e3c --- /dev/null +++ b/examples/tags-with-hits/types/ProductHit.ts @@ -0,0 +1,19 @@ +import { Hit } from '@algolia/client-search'; + +type ProductRecord = { + brand: string; + categories: string[]; + description: string; + image: string; + name: string; + price: number; + rating: number; + url: string; +}; + +type WithAutocompleteAnalytics = THit & { + __autocomplete_indexName: string; + __autocomplete_queryID: string; +}; + +export type ProductHit = WithAutocompleteAnalytics>; diff --git a/examples/tags-with-hits/types/TagExtraData.ts b/examples/tags-with-hits/types/TagExtraData.ts new file mode 100644 index 000000000..769e47dd9 --- /dev/null +++ b/examples/tags-with-hits/types/TagExtraData.ts @@ -0,0 +1 @@ +export type TagExtraData = { facet: string }; diff --git a/examples/tags-with-hits/types/index.ts b/examples/tags-with-hits/types/index.ts new file mode 100644 index 000000000..1344ae054 --- /dev/null +++ b/examples/tags-with-hits/types/index.ts @@ -0,0 +1,2 @@ +export * from './ProductHit'; +export * from './TagExtraData'; diff --git a/packages/autocomplete-core/src/utils/getNormalizedSources.ts b/packages/autocomplete-core/src/utils/getNormalizedSources.ts index 52b63491b..56148d5ff 100644 --- a/packages/autocomplete-core/src/utils/getNormalizedSources.ts +++ b/packages/autocomplete-core/src/utils/getNormalizedSources.ts @@ -1,4 +1,4 @@ -import { invariant, decycle } from '@algolia/autocomplete-shared'; +import { invariant, decycle, noop } from '@algolia/autocomplete-shared'; import { AutocompleteSource, @@ -9,8 +9,6 @@ import { InternalGetSources, } from '../types'; -import { noop } from './noop'; - export function getNormalizedSources( getSources: GetSources, params: GetSourcesParams diff --git a/packages/autocomplete-core/src/utils/index.ts b/packages/autocomplete-core/src/utils/index.ts index 78bfba092..c4245e292 100644 --- a/packages/autocomplete-core/src/utils/index.ts +++ b/packages/autocomplete-core/src/utils/index.ts @@ -4,4 +4,3 @@ export * from './getNormalizedSources'; export * from './getActiveItem'; export * from './isOrContainsNode'; export * from './mapToAlgoliaResponse'; -export * from './noop'; diff --git a/packages/autocomplete-plugin-tags/README.md b/packages/autocomplete-plugin-tags/README.md new file mode 100644 index 000000000..4fc6d3c5f --- /dev/null +++ b/packages/autocomplete-plugin-tags/README.md @@ -0,0 +1,15 @@ +# @algolia/autocomplete-plugin-tags + +The Tags plugin lets you manage and display a list of tags in your autocomplete. + +## Installation + +```sh +yarn add @algolia/autocomplete-plugin-tags +# or +npm install @algolia/autocomplete-plugin-tags +``` + +## Documentation + +See [**Documentation**](https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-tags). diff --git a/packages/autocomplete-plugin-tags/package.json b/packages/autocomplete-plugin-tags/package.json new file mode 100644 index 000000000..bd5646556 --- /dev/null +++ b/packages/autocomplete-plugin-tags/package.json @@ -0,0 +1,43 @@ +{ + "name": "@algolia/autocomplete-plugin-tags", + "description": "A plugin to manage and display a list of tags in Algolia Autocomplete.", + "version": "1.3.0", + "license": "MIT", + "homepage": "https://github.com/algolia/autocomplete", + "repository": "algolia/autocomplete", + "author": { + "name": "Algolia, Inc.", + "url": "https://www.algolia.com" + }, + "source": "src/index.ts", + "types": "dist/esm/index.d.ts", + "module": "dist/esm/index.js", + "main": "dist/umd/index.production.js", + "umd:main": "dist/umd/index.production.js", + "unpkg": "dist/umd/index.production.js", + "jsdelivr": "dist/umd/index.production.js", + "sideEffects": [ + "*.css" + ], + "files": [ + "dist/" + ], + "scripts": { + "build:clean": "rm -rf ./dist", + "build:esm": "babel src --root-mode upward --extensions '.ts,.tsx' --out-dir dist/esm --ignore '**/*/__tests__/'", + "build:types": "tsc -p ./tsconfig.declaration.json --outDir ./dist/esm", + "build:umd": "rollup --config", + "build:css": "yarn build:css:minified && yarn build:css:unminified", + "build:css:minified": "MINIFIED=TRUE node ../../scripts/buildCss.mjs src/theme.scss dist/theme.min.css", + "build:css:unminified": "node ../../scripts/buildCss.mjs src/theme.scss dist/theme.css", + "build": "rm -rf ./dist && yarn build:umd && yarn build:esm && yarn build:types && yarn build:css", + "on:change": "concurrently \"yarn build:esm\" \"yarn build:types\" \"yarn build:css\"", + "prepare": "yarn run build:esm && yarn build:types && yarn build:css", + "watch": "watch \"yarn on:change\" --ignoreDirectoryPattern \"/dist/\"" + }, + "devDependencies": { + "@algolia/autocomplete-core": "1.3.0", + "@algolia/autocomplete-js": "1.3.0", + "@algolia/autocomplete-shared": "1.3.0" + } +} diff --git a/packages/autocomplete-plugin-tags/rollup.config.js b/packages/autocomplete-plugin-tags/rollup.config.js new file mode 100644 index 000000000..099ce0e3a --- /dev/null +++ b/packages/autocomplete-plugin-tags/rollup.config.js @@ -0,0 +1,5 @@ +import { createRollupConfigs } from '../../scripts/rollup/config'; + +import pkg from './package.json'; + +export default createRollupConfigs({ pkg }); diff --git a/packages/autocomplete-plugin-tags/src/__tests__/createTagsPlugin.test.tsx b/packages/autocomplete-plugin-tags/src/__tests__/createTagsPlugin.test.tsx new file mode 100644 index 000000000..6db6a09f7 --- /dev/null +++ b/packages/autocomplete-plugin-tags/src/__tests__/createTagsPlugin.test.tsx @@ -0,0 +1,507 @@ +/** @jsx h */ +import { autocomplete } from '@algolia/autocomplete-js'; +import { fireEvent, waitFor, within } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { h, render, createElement, Fragment } from 'preact'; + +import { createTagsPlugin } from '../createTagsPlugin'; + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('createTagsPlugin', () => { + test('adds a tags source', async () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + const tagsPlugin = createTagsPlugin({ + initialTags: [ + { + label: 'iPhone 12', + }, + ], + }); + + autocomplete({ + container, + panelContainer, + plugins: [tagsPlugin], + }); + + const input = container.querySelector('.aa-Input'); + + fireEvent.input(input, { target: { value: 'a' } }); + + await waitFor(() => { + expect( + within( + panelContainer.querySelector( + '[data-autocomplete-source-id="tagsPlugin"]' + ) + ) + .getAllByRole('option') + .map((option) => option.children) + ).toMatchInlineSnapshot(` + Array [ + HTMLCollection [ +
+ + iPhone 12 + + +
, + ], + ] + `); + }); + }); + + test('transforms the tags source', async () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + const tagsPlugin = createTagsPlugin({ + initialTags: [ + { + label: 'iPhone 12', + }, + ], + transformSource({ source }) { + return { + ...source, + templates: { + item({ item }) { + return item.label; + }, + }, + }; + }, + }); + + autocomplete({ + container, + panelContainer, + plugins: [tagsPlugin], + }); + + const input = container.querySelector('.aa-Input'); + + fireEvent.input(input, { target: { value: 'a' } }); + + await waitFor(() => { + expect( + within( + panelContainer.querySelector( + '[data-autocomplete-source-id="tagsPlugin"]' + ) + ) + .getAllByRole('option') + .map((option) => option.textContent) + ).toEqual(['iPhone 12']); + }); + }); + + test('fully customizes tags rendering', async () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + const tagsPlugin = createTagsPlugin({ + initialTags: [ + { + label: 'iPhone 12', + }, + ], + transformSource() { + return null; + }, + }); + + autocomplete({ + container, + panelContainer, + renderer: { createElement, Fragment }, + getSources() { + return [ + { + sourceId: 'filters', + getItems() { + return [ + { + label: 'iPhone 12', + }, + { + label: 'Samsung Galaxy S', + }, + ]; + }, + templates: { + item({ item }) { + return item.label as string; + }, + }, + }, + ]; + }, + plugins: [tagsPlugin], + render({ sections, state }, root) { + render( +
+ +

Filters

+
    + {state.context.tagsPlugin.tags.map((tag) => ( +
  • tag.remove()} + > + Filter: {tag.label} +
  • + ))} +
+

Suggestions

+
    +
  • + +
  • +
+
{sections}
+
, + root + ); + }, + }); + + const input = container.querySelector('.aa-Input'); + + fireEvent.input(input, { target: { value: 'a' } }); + + await waitFor(() => { + expect( + document.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + panelContainer.querySelector( + '[data-autocomplete-source-id="tagsPlugin"]' + ) + ).toBeNull(); + }); + + await waitFor(() => { + expect( + within(panelContainer).getByRole('listitem', { + name: 'Filter: iPhone 12', + }) + ).toBeInTheDocument(); + }); + + userEvent.click( + within(panelContainer).getByRole('button', { + name: 'Suggestion: Samsung Galaxy S', + }) + ); + + await waitFor(() => { + expect( + within(panelContainer).getByRole('listitem', { + name: 'Filter: Samsung Galaxy S', + }) + ).toBeInTheDocument(); + }); + + userEvent.click( + within(panelContainer).getByRole('button', { name: 'Clear all' }) + ); + + await waitFor(() => { + expect( + within(panelContainer).queryByRole('listitem', { + name: 'Filter: iPhone 12', + }) + ).toBeNull(); + }); + }); + + test('returns the tags', () => { + const tagsPlugin = createTagsPlugin({ + initialTags: [ + { + label: 'iPhone 12', + }, + ], + }); + + expect(tagsPlugin.data.tags).toEqual([ + expect.objectContaining({ label: 'iPhone 12' }), + ]); + }); + + test('adds tags', () => { + const tagsPlugin = createTagsPlugin({ + initialTags: [ + { + label: 'iPhone 12', + }, + ], + }); + + tagsPlugin.data.addTags([{ label: 'Samsung Galaxy S' }]); + + expect(tagsPlugin.data.tags).toEqual([ + expect.objectContaining({ label: 'iPhone 12' }), + expect.objectContaining({ label: 'Samsung Galaxy S' }), + ]); + }); + + test('sets tags', () => { + const tagsPlugin = createTagsPlugin({ + initialTags: [ + { + label: 'iPhone 12', + }, + ], + }); + + tagsPlugin.data.setTags([{ label: 'Samsung Galaxy S' }]); + + expect(tagsPlugin.data.tags).toEqual([ + expect.objectContaining({ label: 'Samsung Galaxy S' }), + ]); + }); + + test('runs a callback when tags change', () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + const onChange = jest.fn(); + + const tagsPlugin = createTagsPlugin({ + initialTags: [ + { + label: 'iPhone 12', + }, + ], + onChange, + }); + + autocomplete({ + container, + panelContainer, + plugins: [tagsPlugin], + }); + + tagsPlugin.data.addTags([{ label: 'Samsung Galaxy S' }]); + + expect(onChange).toHaveBeenNthCalledWith(1, { + onActive: expect.any(Function), + onSelect: expect.any(Function), + refresh: expect.any(Function), + setActiveItemId: expect.any(Function), + setCollections: expect.any(Function), + setContext: expect.any(Function), + setIsOpen: expect.any(Function), + setQuery: expect.any(Function), + setStatus: expect.any(Function), + prevTags: [ + { + label: 'iPhone 12', + remove: expect.any(Function), + }, + ], + tags: [ + { + label: 'iPhone 12', + remove: expect.any(Function), + }, + { + label: 'Samsung Galaxy S', + remove: expect.any(Function), + }, + ], + }); + }); + + test('adds a tag on subscriber item select then removes a tag on tags source item select', async () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + const tagsPlugin = createTagsPlugin({ + getTagsSubscribers() { + return [ + { + sourceId: 'filters', + getTag({ item }) { + return { + label: `Filter: ${item.label}`, + }; + }, + }, + ]; + }, + }); + + autocomplete({ + container, + panelContainer, + getSources() { + return [ + { + sourceId: 'filters', + getItems() { + return [ + { + label: 'iPhone 12', + }, + { + label: 'Samsung Galaxy S', + }, + ]; + }, + templates: { + item({ item }) { + return item.label as string; + }, + }, + }, + ]; + }, + plugins: [tagsPlugin], + }); + + const input = container.querySelector('.aa-Input'); + + fireEvent.input(input, { target: { value: 'a' } }); + + await waitFor(() => { + expect( + document.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + userEvent.click( + within( + panelContainer.querySelector('[data-autocomplete-source-id="filters"]') + ).getByRole('option', { name: 'iPhone 12' }) + ); + + await waitFor(() => { + expect( + within( + panelContainer.querySelector( + '[data-autocomplete-source-id="tagsPlugin"]' + ) + ) + .getAllByRole('option') + .map((node) => node.textContent) + ).toEqual(['Filter: iPhone 12']); + }); + + userEvent.click( + within( + panelContainer.querySelector( + '[data-autocomplete-source-id="tagsPlugin"]' + ) + ).getByRole('option', { name: 'Filter: iPhone 12' }) + ); + + await waitFor(() => { + expect( + panelContainer.querySelector( + '[data-autocomplete-source-id="tagsPlugin"]' + ) + ).toBeNull(); + }); + }); + + test('keeps the panel open on tags update', async () => { + const container = document.createElement('div'); + const panelContainer = document.createElement('div'); + + document.body.appendChild(panelContainer); + + const tagsPlugin = createTagsPlugin({ + initialTags: [ + { + label: 'iPhone 12', + }, + ], + }); + + autocomplete({ + container, + panelContainer, + plugins: [tagsPlugin], + }); + + const input = container.querySelector('.aa-Input'); + + fireEvent.input(input, { target: { value: 'a' } }); + + await waitFor(() => { + expect( + document.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + tagsPlugin.data.addTags([{ label: 'Samsung Galaxy S' }]); + + await waitFor(() => { + expect( + within( + panelContainer.querySelector( + '[data-autocomplete-source-id="tagsPlugin"]' + ) + ) + .getAllByRole('option') + .map((node) => node.textContent) + ).toEqual(['iPhone 12', 'Samsung Galaxy S']); + }); + }); +}); diff --git a/packages/autocomplete-plugin-tags/src/createTags.ts b/packages/autocomplete-plugin-tags/src/createTags.ts new file mode 100644 index 000000000..72a4b9d0b --- /dev/null +++ b/packages/autocomplete-plugin-tags/src/createTags.ts @@ -0,0 +1,74 @@ +import { BaseItem } from '@algolia/autocomplete-core'; +import { createRef } from '@algolia/autocomplete-shared'; + +import { CreateTagsPluginParams } from './createTagsPlugin'; +import { DefaultTagType, BaseTag, Tag } from './types'; + +export type OnTagsChangeParams = { + prevTags: Array>; + tags: Array>; +}; + +type OnTagsChangeListener = ( + params: OnTagsChangeParams +) => void; + +type CreateTagsParams< + TItem extends BaseItem, + TTag extends DefaultTagType = DefaultTagType +> = Pick, 'initialTags'>; + +export function createTags< + TItem extends BaseItem, + TTag extends DefaultTagType = DefaultTagType +>({ initialTags = [] }: CreateTagsParams) { + const tagsRef = createRef(mapToTags(initialTags)); + const onChangeListeners: Array> = []; + + function mapToTags( + baseTags: Array> + ): Array> { + return baseTags.map((baseTag) => { + const tag = { + ...baseTag, + remove() { + const prevTags = tagsRef.current.slice(); + + tagsRef.current = tagsRef.current.filter( + (tagRef) => tag !== ((tagRef as unknown) as Tag) + ); + onChangeListeners.forEach((listener) => + listener({ prevTags, tags: tagsRef.current }) + ); + }, + }; + + return tag; + }); + } + + return { + get() { + return tagsRef.current; + }, + set(baseTags: Array>) { + const prevTags = tagsRef.current.slice(); + + tagsRef.current = mapToTags(baseTags); + onChangeListeners.forEach((listener) => + listener({ prevTags, tags: tagsRef.current }) + ); + }, + add(baseTags: Array>) { + const prevTags = tagsRef.current.slice(); + + tagsRef.current.push(...mapToTags(baseTags)); + onChangeListeners.forEach((listener) => + listener({ prevTags, tags: tagsRef.current }) + ); + }, + onChange(listener: OnTagsChangeListener) { + onChangeListeners.push(listener); + }, + }; +} diff --git a/packages/autocomplete-plugin-tags/src/createTagsPlugin.tsx b/packages/autocomplete-plugin-tags/src/createTagsPlugin.tsx new file mode 100644 index 000000000..0bf1c401b --- /dev/null +++ b/packages/autocomplete-plugin-tags/src/createTagsPlugin.tsx @@ -0,0 +1,170 @@ +/** @jsx createElement */ +import { BaseItem, PluginSubscribeParams } from '@algolia/autocomplete-core'; +import { + AutocompletePlugin, + AutocompleteSource, + AutocompleteState, +} from '@algolia/autocomplete-js'; +import { noop } from '@algolia/autocomplete-shared'; + +import { createTags, OnTagsChangeParams } from './createTags'; +import type { DefaultTagType, BaseTag, Tag } from './types'; + +type OnChangeParams< + TTag extends DefaultTagType = DefaultTagType +> = PluginSubscribeParams & OnTagsChangeParams; + +type GetTagParams = { item: TItem }; + +type TagsPluginData = { + /** + * Returns the current list of tags. + * + * @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-tags/createtagsplugin/#param-tags + */ + tags: Array>; + /** + * Adds tags to the list. + * + * @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-tags/createtagsplugin/#param-addtags + */ + addTags: (tags: Array>) => void; + /** + * Sets the list of tags. + * + * @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-tags/createtagsplugin/#param-settags + */ + setTags: (tags: Array>) => void; +}; + +export type TagsApi< + TTag extends DefaultTagType = DefaultTagType +> = TagsPluginData; + +export type CreateTagsPluginParams< + TItem extends BaseItem, + TTag extends DefaultTagType +> = { + /** + * A set of initial tags to pass to the plugin. + * + * @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-tags/createtagsplugin/#param-initialtags + */ + initialTags?: Array>; + /** + * A function to specify what sources the plugin should subscribe to. The plugin adds a tag when selecting an item from these sources. + * + * @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-tags/createtagsplugin/#param-gettagssubscribers + */ + getTagsSubscribers?(): Array<{ + sourceId: string; + getTag(params: GetTagParams): BaseTag; + }>; + /** + * A function to transform the returned tags source. + * + * @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-tags/createtagsplugin/#param-transformsource + */ + transformSource?(params: { + source: AutocompleteSource>; + state: AutocompleteState>; + }): AutocompleteSource> | undefined; + /** + * A function called when the list of tags changes. + * + * @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-tags/createtagsplugin/#param-onchange + */ + onChange?(params: OnChangeParams): void; +}; + +export function createTagsPlugin< + TItem extends BaseItem, + TTag extends DefaultTagType = DefaultTagType +>({ + initialTags = [], + getTagsSubscribers = () => [], + transformSource = ({ source }) => source, + onChange = noop, +}: CreateTagsPluginParams = {}): AutocompletePlugin< + Tag, + TagsPluginData +> { + const tags = createTags({ initialTags }); + const tagsApi = { setTags: tags.set, addTags: tags.add }; + + return { + subscribe(params) { + const { setContext, onSelect, setIsOpen, refresh } = params; + const subscribers = getTagsSubscribers(); + + setContext({ tagsPlugin: { ...tagsApi, tags: tags.get() } }); + + onSelect(({ source, item }) => { + const subscriber = subscribers.find( + ({ sourceId }) => sourceId === source.sourceId + ); + + if (subscriber) { + tags.add([subscriber.getTag({ item })]); + } + }); + + tags.onChange(({ prevTags }) => { + setContext({ + tagsPlugin: { ...tagsApi, tags: tags.get() }, + }); + + setIsOpen(true); + onChange({ ...params, prevTags, tags: tags.get() }); + refresh(); + }); + }, + getSources({ state }) { + return [ + transformSource({ + source: { + sourceId: 'tagsPlugin', + getItems() { + return tags.get(); + }, + onSelect({ item }) { + item.remove(); + }, + templates: { + item({ item, createElement }) { + return ( +
+ {item.label} + +
+ ); + }, + }, + }, + state: state as AutocompleteState>, + }), + ]; + }, + data: { + ...tagsApi, + get tags() { + return tags.get(); + }, + }, + }; +} diff --git a/packages/autocomplete-plugin-tags/src/index.ts b/packages/autocomplete-plugin-tags/src/index.ts new file mode 100644 index 000000000..63924f34c --- /dev/null +++ b/packages/autocomplete-plugin-tags/src/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './createTagsPlugin'; diff --git a/packages/autocomplete-plugin-tags/src/theme.scss b/packages/autocomplete-plugin-tags/src/theme.scss new file mode 100644 index 000000000..64a5404a2 --- /dev/null +++ b/packages/autocomplete-plugin-tags/src/theme.scss @@ -0,0 +1,48 @@ +[data-autocomplete-source-id='tagsPlugin'] { + margin-bottom: var(--aa-spacing-half); + .aa-List { + display: flex; + flex-wrap: wrap; + margin: calc(var(--aa-spacing-half) / 2 * -1); + } + .aa-Item { + background-color: rgba( + var(--aa-selected-color-rgb), + var(--aa-selected-color-alpha) + ); + margin: calc(var(--aa-spacing-half) / 2); + min-height: auto; + &[aria-selected='true'] { + background-color: rgba(var(--aa-selected-color-rgb), 0.55); + } + } +} + +.aa-TagsPlugin-Tag { + align-items: center; + display: flex; + padding-left: var(--aa-spacing-half); +} + +.aa-TagsPlugin-TagLabel { + font-size: 0.8em; +} + +.aa-TagsPlugin-RemoveButton { + cursor: pointer; + svg { + color: rgba(var(--aa-muted-color-rgb), var(--aa-muted-color-alpha)); + margin: calc(var(--aa-spacing) / 3); + stroke-width: var(--aa-icon-stroke-width); + width: calc(var(--aa-action-icon-size) / 1.5); + } + &:hover, + &:focus { + svg { + color: rgba(var(--aa-text-color-rgb), var(--aa-text-color-alpha)); + } + @media (hover: none) and (pointer: coarse) { + color: inherit; + } + } +} diff --git a/packages/autocomplete-plugin-tags/src/types/Tag.ts b/packages/autocomplete-plugin-tags/src/types/Tag.ts new file mode 100644 index 000000000..9ba909e27 --- /dev/null +++ b/packages/autocomplete-plugin-tags/src/types/Tag.ts @@ -0,0 +1,11 @@ +export type DefaultTagType = Record; + +export type BaseTag = TTag & { + label: string; +}; + +export type Tag< + TTag extends DefaultTagType = DefaultTagType +> = BaseTag & { + remove: () => void; +}; diff --git a/packages/autocomplete-plugin-tags/src/types/index.ts b/packages/autocomplete-plugin-tags/src/types/index.ts new file mode 100644 index 000000000..9790fcbf1 --- /dev/null +++ b/packages/autocomplete-plugin-tags/src/types/index.ts @@ -0,0 +1 @@ +export * from './Tag'; diff --git a/packages/autocomplete-plugin-tags/tsconfig.declaration.json b/packages/autocomplete-plugin-tags/tsconfig.declaration.json new file mode 100644 index 000000000..1e0c6449f --- /dev/null +++ b/packages/autocomplete-plugin-tags/tsconfig.declaration.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.declaration" +} diff --git a/packages/autocomplete-core/src/utils/__tests__/noop.test.ts b/packages/autocomplete-shared/src/__tests__/noop.test.ts similarity index 100% rename from packages/autocomplete-core/src/utils/__tests__/noop.test.ts rename to packages/autocomplete-shared/src/__tests__/noop.test.ts diff --git a/packages/autocomplete-shared/src/index.ts b/packages/autocomplete-shared/src/index.ts index cdcd3b7ff..8ed4431a8 100644 --- a/packages/autocomplete-shared/src/index.ts +++ b/packages/autocomplete-shared/src/index.ts @@ -8,4 +8,5 @@ export * from './getItemsCount'; export * from './invariant'; export * from './isEqual'; export * from './MaybePromise'; +export * from './noop'; export * from './warn'; diff --git a/packages/autocomplete-core/src/utils/noop.ts b/packages/autocomplete-shared/src/noop.ts similarity index 100% rename from packages/autocomplete-core/src/utils/noop.ts rename to packages/autocomplete-shared/src/noop.ts diff --git a/ship.config.js b/ship.config.js index beaa5e8ed..9a21d6727 100644 --- a/ship.config.js +++ b/ship.config.js @@ -12,6 +12,7 @@ module.exports = { 'packages/autocomplete-plugin-algolia-insights', 'packages/autocomplete-plugin-query-suggestions', 'packages/autocomplete-plugin-recent-searches', + 'packages/autocomplete-plugin-tags', 'packages/autocomplete-preset-algolia', 'packages/autocomplete-shared', 'packages/autocomplete-theme-classic',