@algolia/wizard 0.46.0 → 0.48.0-rc.141.344
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -20,14 +20,20 @@ Always install the **latest stable** within the major (use a caret range like `^
|
|
|
20
20
|
|
|
21
21
|
- Use a **search-only API key** in any browser/client code. It is safe to expose.
|
|
22
22
|
NEVER ship an admin or any write-capable key to the client.
|
|
23
|
-
-
|
|
24
|
-
|
|
23
|
+
- Import the Application ID and search-only key from the project's shared client-side
|
|
24
|
+
config module as plain exported constants. They are public, safe to commit — never read
|
|
25
|
+
them from an env var or `.env*` file, and never hardcode them elsewhere.
|
|
25
26
|
- Instantiate the search client **once, outside your components**, and pass a stable
|
|
26
27
|
reference. Do not inline `algoliasearch(...)` as a prop value — it breaks the
|
|
27
28
|
client cache and causes re-renders.
|
|
28
29
|
- For InstantSearch, import the client from `algoliasearch/lite` (smaller bundle and
|
|
29
30
|
correct types — see `instantsearch-setup.md`).
|
|
30
31
|
- Always enable **Insights** on the InstantSearch root with `insights: true`. Without it there are no click/conversion events, so many Algolia features will not work as expected.
|
|
32
|
+
- Vendor CSS must be **global**: import an InstantSearch theme from a JS/TS module or the
|
|
33
|
+
app's global stylesheet and keep `.ais-*` overrides in a global block. In a scoped style
|
|
34
|
+
block (Astro `<style>`, Vue `<style scoped>`, Svelte `<style>`) or a CSS Module, scoping
|
|
35
|
+
rewrites the selectors, the widget's runtime DOM carries no scope attribute, and the
|
|
36
|
+
styling silently does nothing while the build passes.
|
|
31
37
|
|
|
32
38
|
## Files
|
|
33
39
|
|
|
@@ -20,10 +20,18 @@ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
|
|
|
20
20
|
|
|
21
21
|
## Vanilla (`instantsearch.js` v4)
|
|
22
22
|
|
|
23
|
+
Use the `autocomplete` widget for the results panel. It owns focus, blur, outside-click
|
|
24
|
+
dismissal, Up/Down navigation with wrap-around, Enter/Escape/Tab, and the combobox ARIA
|
|
25
|
+
(`combobox`, `aria-expanded`, `aria-activedescendant`) — add no open/close logic or ARIA
|
|
26
|
+
roles of your own.
|
|
27
|
+
|
|
28
|
+
Needs `instantsearch.js` **4.110.0+**. If an older 4.x is pinned, upgrade within `^4`
|
|
29
|
+
rather than switching widgets.
|
|
30
|
+
|
|
23
31
|
```js
|
|
24
32
|
import { liteClient as algoliasearch } from 'algoliasearch/lite'
|
|
25
33
|
import instantsearch from 'instantsearch.js'
|
|
26
|
-
import {
|
|
34
|
+
import { autocomplete } from 'instantsearch.js/es/widgets'
|
|
27
35
|
import {
|
|
28
36
|
ALGOLIA_APP_ID,
|
|
29
37
|
ALGOLIA_SEARCH_API_KEY,
|
|
@@ -39,24 +47,128 @@ const search = instantsearch({
|
|
|
39
47
|
})
|
|
40
48
|
|
|
41
49
|
search.addWidgets([
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
autocomplete({
|
|
51
|
+
container: '#autocomplete',
|
|
52
|
+
placeholder: 'Search...',
|
|
53
|
+
indices: [
|
|
54
|
+
{
|
|
55
|
+
indexName: ALGOLIA_INDEX_NAME,
|
|
56
|
+
getURL: (item) => `/products/${item.handle}`,
|
|
57
|
+
templates: {
|
|
58
|
+
item({ item, onSelect }, { html, components }) {
|
|
59
|
+
return html`<div onClick=${onSelect}>
|
|
60
|
+
${components.Highlight({ hit: item, attribute: 'title' })}
|
|
61
|
+
</div>`
|
|
62
|
+
},
|
|
63
|
+
noResults(_, { html }) {
|
|
64
|
+
return html`<span>No results</span>`
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
}),
|
|
44
70
|
])
|
|
45
71
|
|
|
46
72
|
search.start()
|
|
47
73
|
```
|
|
48
74
|
|
|
75
|
+
### Match entity schema
|
|
76
|
+
|
|
77
|
+
Ensure the attributes you map into the component match the entity. 'title' above is an example.
|
|
78
|
+
|
|
79
|
+
### Navigation is `getURL`'s job, not the template's
|
|
80
|
+
|
|
81
|
+
- `indices[].getURL` — `(item) => string`, a route that already exists; don't invent one.
|
|
82
|
+
It navigates on click **and** on Enter over a highlighted item, and records the click
|
|
83
|
+
event.
|
|
84
|
+
- The `item` template renders content only: **no `<a>`, no `href`, no click handler of
|
|
85
|
+
your own, never `stopPropagation`.** The widget's `<li>` owns the click — it sends the
|
|
86
|
+
Insights `click`, closes the panel, clears the active option.
|
|
87
|
+
- `getSearchPageURL` sends Enter with nothing highlighted to a full results page; pass it
|
|
88
|
+
only if the app has one. `.getQuery` is optional.
|
|
89
|
+
- TypeScript: type records through the generic, not casts inside templates —
|
|
90
|
+
`type ProductHit = Hit<{ title: string; handle: string }>` (`Hit` from
|
|
91
|
+
`instantsearch.js`), then `autocomplete<ProductHit>({ ... })` types `item`, `getURL`,
|
|
92
|
+
and the templates.
|
|
93
|
+
|
|
94
|
+
### Styling the panel
|
|
95
|
+
|
|
96
|
+
The widget ships no CSS. Install `instantsearch.css` and import a theme once, from the
|
|
97
|
+
component's own JS/TS module or the app's global stylesheet:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
import 'instantsearch.css/themes/satellite.css'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The theme and your `.ais-*` overrides must be **global**: never a CSS `@import` inside a
|
|
104
|
+
framework-scoped style block (Astro `<style>`, Vue `<style scoped>`, Svelte `<style>`) or
|
|
105
|
+
a CSS Module.
|
|
106
|
+
Use `<style is:global>` (Astro), an unscoped block or `:deep()` (Vue), or plain global CSS.
|
|
107
|
+
|
|
108
|
+
The theme ships a dark palette behind `:root[data-theme=dark], .dark`: in a dark app set
|
|
109
|
+
`data-theme="dark"` on `<html>` or render under a `.dark` ancestor, then adjust.
|
|
110
|
+
|
|
111
|
+
Palette lives in `--ais-*` custom properties on `:root` as **RGB triplets consumed through
|
|
112
|
+
`rgba()`**, override with triplets only:
|
|
113
|
+
|
|
114
|
+
```css
|
|
115
|
+
:root {
|
|
116
|
+
--ais-background-color-rgb: 24, 24, 27; /* not #18181b */
|
|
117
|
+
--ais-text-color-rgb: 244, 244, 245;
|
|
118
|
+
--ais-primary-color-rgb: 129, 140, 248;
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Everything else is plain CSS against the widget's classes, or `cssClasses` /
|
|
123
|
+
`indices[].cssClasses` to add your own names to the same elements. Who owns what:
|
|
124
|
+
|
|
125
|
+
- `.ais-Autocomplete` — root. Its **container** sets the widget's width; don't size the
|
|
126
|
+
widget from the input.
|
|
127
|
+
- `.ais-AutocompleteForm` — visible frame: background, border, radius, focus ring
|
|
128
|
+
(`:focus-within`). The input's pill goes here.
|
|
129
|
+
- `.ais-AutocompleteInput` — text only; theme sets `border: 0`, `background: none`,
|
|
130
|
+
`width: 100%`. A border or width here leaves the form's frame drawn around it and
|
|
131
|
+
changes no widths.
|
|
132
|
+
- `.ais-AutocompletePanel` (+ `--open`), `.ais-AutocompletePanelLayout` — dropdown surface
|
|
133
|
+
and its scroll container.
|
|
134
|
+
- `.ais-AutocompleteIndex`, `-IndexHeader`, `-IndexList`, `-IndexItem`, `-IndexNoResults`
|
|
135
|
+
— one index's results; the active item carries `aria-selected="true"`.
|
|
136
|
+
- `.ais-AutocompleteItemWrapper`, `-ItemContent`, `-ItemIcon` — inside one result.
|
|
137
|
+
- `.ais-AutocompleteSubmitButton`, `-ClearButton`, `-LoadingIndicator`, `-Label` — form
|
|
138
|
+
controls.
|
|
139
|
+
- `.ais-AutocompleteDetached*` — detached mode (below).
|
|
140
|
+
|
|
141
|
+
The theme's panel is `position: absolute; width: 100%`, so it inherits the input
|
|
142
|
+
container's width — a small input yields an unreadably narrow panel. Give it a min-width
|
|
143
|
+
and anchor it to the edge it opens from, or it grows off-screen:
|
|
144
|
+
|
|
145
|
+
```css
|
|
146
|
+
.ais-AutocompletePanel {
|
|
147
|
+
min-width: 320px;
|
|
148
|
+
left: 0; /* right: 0; left: auto for an input aligned to the right */
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Below `(max-width: 680px)` the widget goes detached (fullscreen): the inline form becomes
|
|
153
|
+
a `.ais-AutocompleteDetachedSearchButton`, form and results move into an overlay
|
|
154
|
+
(`.ais-AutocompleteDetachedOverlay`, `.ais-AutocompleteDetachedContainer`,
|
|
155
|
+
`.ais-AutocompleteDetachedFormContainer`). So panel-width rules apply only above that
|
|
156
|
+
breakpoint, and styling the inline form leaves the mobile trigger button on the theme's
|
|
157
|
+
look unless you style it too. Change it with `detachedMediaQuery` or
|
|
158
|
+
`--ais-autocomplete-detached-media-query`; pass `''` to disable detached mode entirely.
|
|
159
|
+
|
|
49
160
|
## Insights (click analytics) — always enable it
|
|
50
161
|
|
|
51
162
|
The `insights` option turns on the Insights middleware (`instantsearch.js` v4.55+),
|
|
52
163
|
which adds `clickAnalytics: true` to every query and keeps the `userToken` in sync
|
|
53
164
|
between search and event calls. Never set `clickAnalytics` or `userToken` on the `configure` widget by hand.
|
|
54
165
|
|
|
55
|
-
Use `insights: true
|
|
56
|
-
itself; do not install the package
|
|
166
|
+
Use `insights: true` and nothing else. The middleware loads `search-insights` from the
|
|
167
|
+
jsDelivr CDN itself; do not install the package, pass `insightsClient`, or declare
|
|
168
|
+
`window.aa`.
|
|
57
169
|
|
|
58
|
-
|
|
59
|
-
|
|
170
|
+
Only if the project has a Content-Security-Policy blocking jsDelivr (the CDN script then
|
|
171
|
+
cannot load): install `search-insights` from npm and pass its default export as
|
|
60
172
|
`insightsClient` in the `insights` option:
|
|
61
173
|
|
|
62
174
|
```js
|
|
@@ -74,9 +186,7 @@ manual `init` call is needed. Keep using the `insightsInitParams` option (for
|
|
|
74
186
|
example `useCookie`) the same way as with the CDN path.
|
|
75
187
|
|
|
76
188
|
The middleware sets an anonymous `userToken` itself, so events fire with no extra
|
|
77
|
-
code.
|
|
78
|
-
enough for search→click attribution within a session but not across sessions, and not
|
|
79
|
-
enough for Personalization to build a profile. To persist it in the first-party
|
|
189
|
+
code. To persist it in the first-party
|
|
80
190
|
`_ALGOLIA` cookie, switch to the object form — only where the app's cookie-consent
|
|
81
191
|
policy allows it:
|
|
82
192
|
|
|
@@ -90,27 +200,16 @@ insights: {
|
|
|
90
200
|
|
|
91
201
|
### Click and conversion events
|
|
92
202
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
203
|
+
With `insights: true`, `autocomplete` sends `view` (on render) and `click` (from the
|
|
204
|
+
result element's own handler) itself. Its item template gets `{ item, onSelect }` and
|
|
205
|
+
**no `sendEvent`** — nothing to wire. Call `onSelect` for the widget's selection behavior
|
|
206
|
+
(close the panel, record a recent search); the click event fires either way.
|
|
96
207
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
container: '#hits',
|
|
100
|
-
templates: {
|
|
101
|
-
item(hit, { html, sendEvent }) {
|
|
102
|
-
return html`<a
|
|
103
|
-
href="${hit.url}"
|
|
104
|
-
onClick="${() => sendEvent('click', hit, 'Hit Clicked')}"
|
|
105
|
-
>${hit.name}</a
|
|
106
|
-
>`
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
})
|
|
110
|
-
```
|
|
208
|
+
`hits` and `infiniteHits` behave the same; only a custom `item` template whose click
|
|
209
|
+
target is your own element needs wiring — it gets `sendEvent` as the second argument.
|
|
111
210
|
|
|
112
|
-
Conversions are business actions (add to cart, signup, booking)
|
|
113
|
-
real action,
|
|
211
|
+
Conversions are business actions (add to cart, signup, booking) away from the results
|
|
212
|
+
panel: wire them at the real action, in a component with its own `sendEvent`:
|
|
114
213
|
|
|
115
214
|
```js
|
|
116
215
|
sendEvent('conversion', hit, 'Product Added To Cart', {
|
|
@@ -161,14 +260,3 @@ import aa from 'search-insights'
|
|
|
161
260
|
aa('setAuthenticatedUserToken', userId)
|
|
162
261
|
aa('setAuthenticatedUserToken', undefined) // on logout
|
|
163
262
|
```
|
|
164
|
-
|
|
165
|
-
## Frameworks without their own flavor
|
|
166
|
-
|
|
167
|
-
Only React and Vue have a maintained InstantSearch wrapper. For any other
|
|
168
|
-
framework — Angular, Svelte, Solid — drive `instantsearch.js` directly from a
|
|
169
|
-
component, mounting widgets against element refs and calling `search.dispose()`
|
|
170
|
-
on teardown.
|
|
171
|
-
|
|
172
|
-
Do NOT install `angular-instantsearch` or a community `*-instantsearch` package:
|
|
173
|
-
`angular-instantsearch` is deprecated and archived (last release v4.4.3, Sep 2024),
|
|
174
|
-
is incompatible with Angular v13+, and receives no security fixes.
|
|
@@ -20,9 +20,17 @@ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
|
|
|
20
20
|
|
|
21
21
|
## React (`react-instantsearch` v7)
|
|
22
22
|
|
|
23
|
+
Use the `Autocomplete` widget for the results panel. It owns focus, blur, outside-click
|
|
24
|
+
dismissal, Up/Down navigation with wrap-around, Enter/Escape/Tab, and the combobox ARIA
|
|
25
|
+
(`combobox` on the input, `aria-expanded`, `aria-activedescendant`) — add no open/close
|
|
26
|
+
logic or ARIA roles of your own.
|
|
27
|
+
|
|
28
|
+
Needs `react-instantsearch` **7.18.0+**. If an older 7.x is pinned, upgrade within `^7`
|
|
29
|
+
rather than switching widgets.
|
|
30
|
+
|
|
23
31
|
```tsx
|
|
24
32
|
import { liteClient as algoliasearch } from 'algoliasearch/lite'
|
|
25
|
-
import { InstantSearch,
|
|
33
|
+
import { InstantSearch, Autocomplete } from 'react-instantsearch'
|
|
26
34
|
import {
|
|
27
35
|
ALGOLIA_APP_ID,
|
|
28
36
|
ALGOLIA_SEARCH_API_KEY,
|
|
@@ -38,13 +46,109 @@ export function Search() {
|
|
|
38
46
|
indexName={ALGOLIA_INDEX_NAME}
|
|
39
47
|
insights={true}
|
|
40
48
|
>
|
|
41
|
-
<
|
|
42
|
-
|
|
49
|
+
<Autocomplete
|
|
50
|
+
placeholder="Search..."
|
|
51
|
+
indices={[
|
|
52
|
+
{
|
|
53
|
+
indexName: ALGOLIA_INDEX_NAME,
|
|
54
|
+
getURL: (item) => `/products/${item.handle}`,
|
|
55
|
+
itemComponent: ({ item, onSelect }) => (
|
|
56
|
+
<div onClick={onSelect}>{item.title}</div>
|
|
57
|
+
),
|
|
58
|
+
noResultsComponent: () => <span>No results</span>,
|
|
59
|
+
},
|
|
60
|
+
]}
|
|
61
|
+
/>
|
|
43
62
|
</InstantSearch>
|
|
44
63
|
)
|
|
45
64
|
}
|
|
46
65
|
```
|
|
47
66
|
|
|
67
|
+
### Match entity schema
|
|
68
|
+
|
|
69
|
+
Ensure the attributes you map into the component match the entity. 'title' above is an example.
|
|
70
|
+
|
|
71
|
+
### Navigation is `getURL`'s job, not the item component's
|
|
72
|
+
|
|
73
|
+
- `indices[].getURL` — `(item) => string`, a route that already exists; don't invent one.
|
|
74
|
+
It navigates on click **and** on Enter over a highlighted item, and records the click
|
|
75
|
+
event.
|
|
76
|
+
- `itemComponent` renders content only: **no `<a>`, no `href`, no `onClick` beyond the
|
|
77
|
+
`onSelect` it is handed, never `stopPropagation`.** The widget's `<li>` owns the click —
|
|
78
|
+
it sends the Insights `click`, closes the panel, clears the active option.
|
|
79
|
+
- `getSearchPageURL` sends Enter with nothing highlighted to a full results page; pass it
|
|
80
|
+
only if the app has one. `.getQuery` is optional.
|
|
81
|
+
- TypeScript: type records through the generic, not casts inside `itemComponent` —
|
|
82
|
+
`type ProductHit = Hit<{ title: string; handle: string }>` (`Hit` from
|
|
83
|
+
`instantsearch.js`), then `<Autocomplete<ProductHit> ... />` types `item`, `getURL`, and
|
|
84
|
+
`itemComponent`.
|
|
85
|
+
|
|
86
|
+
### Styling the panel
|
|
87
|
+
|
|
88
|
+
The widget ships no CSS. Install `instantsearch.css` and import a theme once, from the
|
|
89
|
+
component module or the app's global stylesheet:
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
import 'instantsearch.css/themes/satellite.css'
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The theme and your `.ais-*` overrides must be **global**: never route either through a CSS
|
|
96
|
+
Module, a scoped style block (Astro `<style>`, Vue `<style scoped>`, Svelte `<style>`), or
|
|
97
|
+
any styled-components-style wrapper that rewrites selectors. Keep them in a global
|
|
98
|
+
stylesheet, or add your own class names via `classNames` and style those.
|
|
99
|
+
|
|
100
|
+
The theme ships a dark palette behind `:root[data-theme=dark], .dark`: in a dark app set
|
|
101
|
+
`data-theme="dark"` on `<html>` or render under a `.dark` ancestor, then adjust.
|
|
102
|
+
|
|
103
|
+
Palette lives in `--ais-*` custom properties on `:root` as **RGB triplets consumed through
|
|
104
|
+
`rgba()`**, override with triplets only:
|
|
105
|
+
|
|
106
|
+
```css
|
|
107
|
+
:root {
|
|
108
|
+
--ais-background-color-rgb: 24, 24, 27; /* not #18181b */
|
|
109
|
+
--ais-text-color-rgb: 244, 244, 245;
|
|
110
|
+
--ais-primary-color-rgb: 129, 140, 248;
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Everything else is plain CSS against the widget's classes, or the `classNames` /
|
|
115
|
+
`indices[].classNames` props to add your own names to the same elements. Who owns what:
|
|
116
|
+
|
|
117
|
+
- `.ais-Autocomplete` — root. Its **container** sets the widget's width; don't size the
|
|
118
|
+
widget from the input.
|
|
119
|
+
- `.ais-AutocompleteForm` — visible frame: background, border, radius, focus ring
|
|
120
|
+
(`:focus-within`). The input's pill goes here.
|
|
121
|
+
- `.ais-AutocompleteInput` — text only; theme sets `border: 0`, `background: none`,
|
|
122
|
+
`width: 100%`. A border or width here leaves the form's frame drawn around it and
|
|
123
|
+
changes no widths.
|
|
124
|
+
- `.ais-AutocompletePanel` (+ `--open`), `.ais-AutocompletePanelLayout` — dropdown surface
|
|
125
|
+
and its scroll container.
|
|
126
|
+
- `.ais-AutocompleteIndex`, `-IndexHeader`, `-IndexList`, `-IndexItem`, `-IndexNoResults`
|
|
127
|
+
— one index's results; the active item carries `aria-selected="true"`.
|
|
128
|
+
- `.ais-AutocompleteItemWrapper`, `-ItemContent`, `-ItemIcon` — inside one result.
|
|
129
|
+
- `.ais-AutocompleteSubmitButton`, `-ClearButton`, `-LoadingIndicator`, `-Label` — form
|
|
130
|
+
controls.
|
|
131
|
+
- `.ais-AutocompleteDetached*` — detached mode (below).
|
|
132
|
+
|
|
133
|
+
The theme's panel is `position: absolute; width: 100%`, so it inherits the input
|
|
134
|
+
container's width — a small input yields an unreadably narrow panel. Give it a min-width
|
|
135
|
+
and anchor it to the edge it opens from, or it grows off-screen:
|
|
136
|
+
|
|
137
|
+
```css
|
|
138
|
+
.ais-AutocompletePanel {
|
|
139
|
+
min-width: 320px;
|
|
140
|
+
left: 0; /* right: 0; left: auto for an input aligned to the right */
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Below `(max-width: 680px)` the widget goes detached (fullscreen): the inline form becomes
|
|
145
|
+
a `.ais-AutocompleteDetachedSearchButton`, form and results move into an overlay
|
|
146
|
+
(`.ais-AutocompleteDetachedOverlay`, `.ais-AutocompleteDetachedContainer`,
|
|
147
|
+
`.ais-AutocompleteDetachedFormContainer`). So panel-width rules apply only above that
|
|
148
|
+
breakpoint, and styling the inline form leaves the mobile trigger button on the theme's
|
|
149
|
+
look unless you style it too. Change it with `detachedMediaQuery` or
|
|
150
|
+
`--ais-autocomplete-detached-media-query`; pass `''` to disable detached mode entirely.
|
|
151
|
+
|
|
48
152
|
Do not inline `searchClient={algoliasearch(...)}` — keep the stable reference above.
|
|
49
153
|
|
|
50
154
|
## Insights (click analytics) — always enable it
|
|
@@ -53,17 +157,24 @@ The `insights` prop turns on the Insights middleware, which adds
|
|
|
53
157
|
`clickAnalytics: true` to every query and keeps the `userToken` in sync between search
|
|
54
158
|
and event calls. Never set `clickAnalytics` or `userToken` on `<Configure>` by hand.
|
|
55
159
|
|
|
56
|
-
Use `insights={true}
|
|
57
|
-
itself; do not install the package
|
|
160
|
+
Use `insights={true}` and nothing else. The middleware loads `search-insights` from the
|
|
161
|
+
jsDelivr CDN itself; do not install the package, pass `insightsClient`, or declare
|
|
162
|
+
`window.aa`.
|
|
58
163
|
|
|
59
|
-
|
|
60
|
-
|
|
164
|
+
Only if the project has a Content-Security-Policy blocking jsDelivr (the CDN script then
|
|
165
|
+
cannot load): install `search-insights` from npm and pass its default export as
|
|
61
166
|
`insightsClient`:
|
|
62
167
|
|
|
63
168
|
```tsx
|
|
64
169
|
import aa from 'search-insights'
|
|
65
170
|
|
|
66
|
-
|
|
171
|
+
export function Search() {
|
|
172
|
+
return (
|
|
173
|
+
<InstantSearch insights={{ insightsClient: aa }}>
|
|
174
|
+
{/* widgets */}
|
|
175
|
+
</InstantSearch>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
67
178
|
```
|
|
68
179
|
|
|
69
180
|
The middleware calls `aa('init', ...)` itself with the app ID and search key, so no
|
|
@@ -71,9 +182,7 @@ manual `init` call is needed. Keep using the `insightsInitParams` option (for
|
|
|
71
182
|
example `useCookie`) the same way as with the CDN path.
|
|
72
183
|
|
|
73
184
|
The middleware sets an anonymous `userToken` itself, so events fire with no extra
|
|
74
|
-
code.
|
|
75
|
-
enough for search→click attribution within a session but not across sessions, and not
|
|
76
|
-
enough for Personalization to build a profile. To persist it in the first-party
|
|
185
|
+
code. To persist it in the first-party
|
|
77
186
|
`_ALGOLIA` cookie, switch to the object form — only where the app's cookie-consent
|
|
78
187
|
policy allows it:
|
|
79
188
|
|
|
@@ -83,8 +192,13 @@ policy allows it:
|
|
|
83
192
|
|
|
84
193
|
### Click and conversion events
|
|
85
194
|
|
|
86
|
-
|
|
87
|
-
|
|
195
|
+
With `insights={true}`, `<Autocomplete>` sends `view` (on render) and `click` (from the
|
|
196
|
+
result element's own handler) itself. Its `itemComponent` receives
|
|
197
|
+
`{ item, onSelect, onApply }` and **no `sendEvent`** — nothing to wire. Call `onSelect`
|
|
198
|
+
for the widget's selection behavior (close the panel, record a recent search); the click
|
|
199
|
+
event fires either way.
|
|
200
|
+
|
|
201
|
+
`<Hits>` and `<InfiniteHits>` behave the same way. Two cases there need explicit wiring:
|
|
88
202
|
|
|
89
203
|
- A custom `hitComponent` whose click target is your own element — it receives a
|
|
90
204
|
`sendEvent` prop.
|
|
@@ -20,19 +20,68 @@ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
|
|
|
20
20
|
|
|
21
21
|
## Vue (`vue-instantsearch` v4)
|
|
22
22
|
|
|
23
|
+
Use `ais-autocomplete` for the query and results state. It is **headless**: its default
|
|
24
|
+
slot provides only `currentRefinement`, `indices`, and `refine` — it renders nothing and
|
|
25
|
+
handles no focus, blur, keyboard, or ARIA. Build all of it yourself: panel closed until
|
|
26
|
+
the input is focused and has a query; close on Escape, blur, or outside click; active-option
|
|
27
|
+
highlight on Up/Down with Enter to select; combobox ARIA on the input; click events via
|
|
28
|
+
each index's `sendEvent`.
|
|
29
|
+
|
|
23
30
|
```vue
|
|
24
31
|
<template>
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
<div ref="containerRef" class="search" @focusout="onFocusOut">
|
|
33
|
+
<ais-instant-search
|
|
34
|
+
:search-client="searchClient"
|
|
35
|
+
:index-name="ALGOLIA_INDEX_NAME"
|
|
36
|
+
:insights="true"
|
|
37
|
+
>
|
|
38
|
+
<ais-autocomplete>
|
|
39
|
+
<template v-slot="{ currentRefinement, indices, refine }">
|
|
40
|
+
<input
|
|
41
|
+
type="search"
|
|
42
|
+
role="combobox"
|
|
43
|
+
aria-autocomplete="list"
|
|
44
|
+
aria-controls="search-panel"
|
|
45
|
+
:aria-expanded="isOpen(currentRefinement)"
|
|
46
|
+
:aria-activedescendant="
|
|
47
|
+
active >= 0 ? `search-option-${active}` : undefined
|
|
48
|
+
"
|
|
49
|
+
:value="currentRefinement"
|
|
50
|
+
@focus="open = true"
|
|
51
|
+
@input="onInput(refine, $event)"
|
|
52
|
+
@keydown.down.prevent="move(1, indices)"
|
|
53
|
+
@keydown.up.prevent="move(-1, indices)"
|
|
54
|
+
@keydown.enter.prevent="select(indices, active)"
|
|
55
|
+
@keydown.esc="close"
|
|
56
|
+
/>
|
|
57
|
+
<ul
|
|
58
|
+
v-show="isOpen(currentRefinement)"
|
|
59
|
+
id="search-panel"
|
|
60
|
+
class="search-panel"
|
|
61
|
+
role="listbox"
|
|
62
|
+
aria-label="Search results"
|
|
63
|
+
>
|
|
64
|
+
<li
|
|
65
|
+
v-for="(hit, i) in indices[0]?.hits ?? []"
|
|
66
|
+
:id="`search-option-${i}`"
|
|
67
|
+
:key="hit.objectID"
|
|
68
|
+
role="option"
|
|
69
|
+
:aria-selected="i === active"
|
|
70
|
+
:class="{ 'is-active': i === active }"
|
|
71
|
+
@mousedown.prevent="select(indices, i)"
|
|
72
|
+
@mousemove="active = i"
|
|
73
|
+
>
|
|
74
|
+
<ais-highlight attribute="name" :hit="hit" />
|
|
75
|
+
</li>
|
|
76
|
+
</ul>
|
|
77
|
+
</template>
|
|
78
|
+
</ais-autocomplete>
|
|
79
|
+
</ais-instant-search>
|
|
80
|
+
</div>
|
|
33
81
|
</template>
|
|
34
82
|
|
|
35
83
|
<script setup>
|
|
84
|
+
import { ref } from 'vue'
|
|
36
85
|
import { liteClient as algoliasearch } from 'algoliasearch/lite'
|
|
37
86
|
import {
|
|
38
87
|
ALGOLIA_APP_ID,
|
|
@@ -41,9 +90,71 @@ import {
|
|
|
41
90
|
} from '<the project's shared config module>'
|
|
42
91
|
|
|
43
92
|
const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
|
|
93
|
+
const open = ref(false)
|
|
94
|
+
const active = ref(-1)
|
|
95
|
+
const containerRef = ref(null)
|
|
96
|
+
|
|
97
|
+
function isOpen(currentRefinement) {
|
|
98
|
+
return open.value && Boolean(currentRefinement)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function onInput(refine, event) {
|
|
102
|
+
open.value = true
|
|
103
|
+
active.value = -1
|
|
104
|
+
refine(event.currentTarget.value)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function move(delta, indices) {
|
|
108
|
+
const hits = indices[0]?.hits ?? []
|
|
109
|
+
if (!hits.length) return
|
|
110
|
+
open.value = true
|
|
111
|
+
const next = active.value + delta
|
|
112
|
+
active.value = next < 0 ? hits.length - 1 : next % hits.length
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function select(indices, index) {
|
|
116
|
+
const hit = indices[0]?.hits?.[index]
|
|
117
|
+
if (!hit) return
|
|
118
|
+
indices[0].sendEvent('click', hit, 'Hit Clicked')
|
|
119
|
+
close()
|
|
120
|
+
// TODO: navigate to this hit's page, where the app has one
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function close() {
|
|
124
|
+
open.value = false
|
|
125
|
+
active.value = -1
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function onFocusOut(event) {
|
|
129
|
+
if (!containerRef.value?.contains(event.relatedTarget)) close()
|
|
130
|
+
}
|
|
44
131
|
</script>
|
|
45
132
|
```
|
|
46
133
|
|
|
134
|
+
Details that break accessibility if changed:
|
|
135
|
+
|
|
136
|
+
- `role="combobox"` on the `input`, not a wrapper — with `aria-controls` at the popup's
|
|
137
|
+
`id` and `aria-activedescendant` at the active option's `id`. Focus stays on the input
|
|
138
|
+
while the arrows move the highlight.
|
|
139
|
+
- The `role="listbox"` element must be the direct parent of the `role="option"` elements;
|
|
140
|
+
a `ul` inside a `listbox` div, or options one level deeper, breaks the a11y tree.
|
|
141
|
+
- `v-show`, not `v-if`, so the `aria-activedescendant` target stays in the DOM.
|
|
142
|
+
- `@mousedown.prevent` on an option, not `@click` — selection must land before `focusout`
|
|
143
|
+
closes the panel.
|
|
144
|
+
- `focusout` on the wrapper is the outside-click dismissal (`event.relatedTarget` is
|
|
145
|
+
`null` for a click on non-focusable chrome). Don't add a `document` click listener on
|
|
146
|
+
top — it fights `@mousedown.prevent`.
|
|
147
|
+
- `.is-active` needs a visible style, not just `aria-selected` — it is a keyboard user's
|
|
148
|
+
only focus feedback, since focus never leaves the input.
|
|
149
|
+
|
|
150
|
+
The panel is your own markup with no widget theme to inherit: give `.search-panel` a
|
|
151
|
+
background from the app's theme tokens (never transparent over page content), text
|
|
152
|
+
clearing 4.5:1 against it, `position: absolute` with `min-width: 320px` anchored to the
|
|
153
|
+
input edge it opens from, and a `max-height` with `overflow-y: auto`.
|
|
154
|
+
|
|
155
|
+
`indices` supports federated search across indices; this project has one, so `indices[0]`
|
|
156
|
+
is always the one to read.
|
|
157
|
+
|
|
47
158
|
Register the widgets via the InstantSearch Vue plugin in your app entry:
|
|
48
159
|
|
|
49
160
|
```js
|
|
@@ -57,12 +168,11 @@ The `insights` prop turns on the Insights middleware, which adds
|
|
|
57
168
|
`clickAnalytics: true` to every query and keeps the `userToken` in sync between search
|
|
58
169
|
and event calls. Never set `clickAnalytics` or `userToken` on `ais-configure` by hand.
|
|
59
170
|
|
|
60
|
-
Use `:insights="true"
|
|
61
|
-
the package or
|
|
171
|
+
Use `:insights="true"` and nothing else. The middleware loads `search-insights` from the
|
|
172
|
+
jsDelivr CDN itself; do not install the package, pass `insightsClient`, or declare `window.aa`.
|
|
62
173
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
`insightsClient`:
|
|
174
|
+
Only if the project has a Content-Security-Policy blocking jsDelivr (the CDN script then cannot
|
|
175
|
+
load): install `search-insights` from npm and pass its default export as `insightsClient`:
|
|
66
176
|
|
|
67
177
|
```js
|
|
68
178
|
import aa from 'search-insights'
|
|
@@ -73,9 +183,7 @@ The middleware calls `aa('init', ...)` itself with the app ID and search key, so
|
|
|
73
183
|
manual `init` call is needed.
|
|
74
184
|
|
|
75
185
|
The middleware sets an anonymous `userToken` itself, so events fire with no extra
|
|
76
|
-
code.
|
|
77
|
-
enough for search→click attribution within a session but not across sessions, and not
|
|
78
|
-
enough for Personalization to build a profile. To persist it in the first-party
|
|
186
|
+
code. To persist it in the first-party
|
|
79
187
|
`_ALGOLIA` cookie, bind the object form — only where the app's cookie-consent policy
|
|
80
188
|
allows it:
|
|
81
189
|
|
|
@@ -85,19 +193,18 @@ const insights = { insightsInitParams: { useCookie: true } } // then :insights="
|
|
|
85
193
|
|
|
86
194
|
### Click and conversion events
|
|
87
195
|
|
|
88
|
-
`ais-
|
|
89
|
-
click
|
|
196
|
+
`ais-autocomplete` wraps the same connector as the other flavors, so `view` fires on its
|
|
197
|
+
own when results render. `click` does not — the option markup is yours: call the index's
|
|
198
|
+
`sendEvent` from your own option handler, as in the setup example above.
|
|
90
199
|
|
|
91
200
|
```vue
|
|
92
|
-
<
|
|
93
|
-
<template v-slot:item="{ item, sendEvent }">
|
|
94
|
-
<a :href="item.url" @click="sendEvent('click', item, 'Hit Clicked')">
|
|
95
|
-
{{ item.name }}
|
|
96
|
-
</a>
|
|
97
|
-
</template>
|
|
98
|
-
</ais-hits>
|
|
201
|
+
<li @mousedown.prevent="indices[0].sendEvent('click', hit, 'Hit Clicked')">
|
|
99
202
|
```
|
|
100
203
|
|
|
204
|
+
`ais-hits` (if the app also has a full results page) sends both itself; only a custom
|
|
205
|
+
`item` slot whose click target is your own element needs wiring — that slot provides
|
|
206
|
+
`sendEvent`.
|
|
207
|
+
|
|
101
208
|
Conversions are business actions (add to cart, signup, booking), so wire them at the
|
|
102
209
|
real action, not at render:
|
|
103
210
|
|