@algolia/wizard 0.42.0 → 0.43.0-rc.141.299
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.
package/dist/main.js
CHANGED
|
@@ -3472,7 +3472,7 @@ async function runAnalysis(mode, extraInstructions = []) {
|
|
|
3472
3472
|
// package.json
|
|
3473
3473
|
var package_default = {
|
|
3474
3474
|
name: "@algolia/wizard",
|
|
3475
|
-
version: "0.
|
|
3475
|
+
version: "0.43.0-rc.141.299",
|
|
3476
3476
|
description: "Magically implement Algolia functionality in your codebase",
|
|
3477
3477
|
type: "module",
|
|
3478
3478
|
engines: {
|
|
@@ -4132,6 +4132,7 @@ function searchInstructions(input) {
|
|
|
4132
4132
|
return [
|
|
4133
4133
|
"Implement an in-app Algolia search experience.",
|
|
4134
4134
|
`Build the search UI for ${input.searchUiTarget}.`,
|
|
4135
|
+
"This step is search UI only \u2014 the index is already populated. Do not create or modify ingestion scripts, rake/manage/CLI tasks, migrations, seeders, or any other data-loading code, even if you notice the data looks incomplete.",
|
|
4135
4136
|
...doc ? [
|
|
4136
4137
|
"Follow the Algolia SDK reference below for client setup, search UI wiring, and Insights instrumentation \u2014 Insights is required, not optional; prefer the reference over prior knowledge:",
|
|
4137
4138
|
doc
|
|
@@ -4139,9 +4140,15 @@ function searchInstructions(input) {
|
|
|
4139
4140
|
"No bundled Algolia SDK reference exists for this stack, so rely on the project's own conventions and Algolia's official client for its language. Do not invent APIs \u2014 keep to the documented search endpoint and its parameters."
|
|
4140
4141
|
],
|
|
4141
4142
|
`Create the search experience as its own component in a new file, following the project's existing component conventions (location, naming, styling approach). Do not write it inline into an existing file.`,
|
|
4142
|
-
`Import and render that new component from ${input.searchLocation ? `"${input.searchLocation}"` : "the best shared, always-rendered layout location (e.g. a header/nav component)"} so it is reachable across the app \u2014 at least a working search input and results
|
|
4143
|
+
`Import and render that new component from ${input.searchLocation ? `"${input.searchLocation}"` : "the best shared, always-rendered layout location (e.g. a header/nav component)"} so it is reachable across the app \u2014 at least a working search input and results panel against the target index.`,
|
|
4143
4144
|
"If a search box already exists, replace its usage with an import and render of your new component; remove the old implementation.",
|
|
4144
4145
|
"When rendering results with an existing shared component (e.g. a card), import and reuse that component rather than inlining its markup \u2014 inlining silently drops the styles and behavior its own file provides.",
|
|
4146
|
+
"Results appear as a dropdown panel, not a permanently visible list. If the SDK reference above documents an Autocomplete widget for this stack, it already owns that behavior \u2014 when the panel opens and closes, Escape, blur, outside click, arrow-key navigation, combobox ARIA, Insights events \u2014 so use it as-is and add no open/close logic, query gating, or ARIA roles of your own, even if it opens on focus before anything is typed. Only build this by hand when the reference has no such widget for this stack, or says it is headless; then show the panel only while the input is focused and has a non-empty query, close it on Escape, on blur, and on an outside click, and move an active-result highlight with Up/Down arrows with Enter to select.",
|
|
4147
|
+
"Let the widget own result selection: where the hits have a detail page, pass its per-index URL option (getURL or the flavor's equivalent) so it navigates on click and on Enter with a result highlighted, and keep your item template or item component to content only \u2014 no anchor, no href, no click handler of your own, no stopPropagation. The widget's own result element is what sends the Insights click event, closes the panel, and clears the active option; wrapping a result in your own link swallows all three while still navigating, so it looks correct and silently loses click analytics.",
|
|
4148
|
+
"Render the zero-results state through the widget's own no-results template or component, so a query that matches nothing shows a message rather than an empty panel.",
|
|
4149
|
+
"Meet WCAG AA contrast (4.5:1 body text, 3:1 large text/icons) between the panel's text and its own background, and give both the input and the active result a focus indicator that is visible against whatever sits behind it. Style the panel through the widget's own class and CSS-variable overrides (or, if building by hand, the app's existing theme tokens) \u2014 never assume a light surface or reuse the surrounding page's colors unchanged inside the panel.",
|
|
4150
|
+
"A vendor theme stylesheet, and any CSS you write against vendor class names, must be global: import the theme from the component's own script module or the app's global stylesheet, and put the overrides in a global block (Astro's <style is:global>, an unscoped Vue block, a plain global CSS file). Never route either through a framework-scoped style block or a CSS Module \u2014 scoping rewrites the vendor selectors, and the widget builds its DOM at runtime with no scope attribute on it, so not one rule matches: the styling silently does nothing and the build still passes.",
|
|
4151
|
+
"The panel takes its width from the input by default, so a small input makes it unreadably narrow: give it a minimum width of 320px independent of the input, and keep it anchored to the input edge it opens from so widening it does not push it off-screen.",
|
|
4145
4152
|
`Define ${SEARCH_CONFIG_APP_ID}, ${SEARCH_CONFIG_SEARCH_KEY}, and ${SEARCH_CONFIG_INDEX_NAME} as exported constants in a module that fits this project's existing conventions for shared client-side config \u2014 reuse an existing one if it already holds config like this, or add a small new one otherwise. These are PUBLIC values, safe to commit and expose client-side: never read them from an environment variable or a .env* file, and never hardcode them anywhere except in that one module (import them wherever the search client needs them).`,
|
|
4146
4153
|
`Set ${SEARCH_CONFIG_APP_ID} to "${input.appId}" and ${SEARCH_CONFIG_INDEX_NAME} to "${input.targetIndex}".`,
|
|
4147
4154
|
input.searchKey ? `Set ${SEARCH_CONFIG_SEARCH_KEY} to "${input.searchKey}".` : `A real search-only key could not be provisioned${input.searchKeyError ? ` (${input.searchKeyError})` : ""} \u2014 set ${SEARCH_CONFIG_SEARCH_KEY} to the placeholder "${SEARCH_KEY_PLACEHOLDER}" and add a prominent TODO for the developer to fill in a real one.`,
|
|
@@ -20,14 +20,21 @@ 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, where they are plain exported constants. They are public values, safe to
|
|
25
|
+
commit — do not read them from an env var or `.env*` file, and never hardcode them
|
|
26
|
+
inline anywhere else.
|
|
25
27
|
- Instantiate the search client **once, outside your components**, and pass a stable
|
|
26
28
|
reference. Do not inline `algoliasearch(...)` as a prop value — it breaks the
|
|
27
29
|
client cache and causes re-renders.
|
|
28
30
|
- For InstantSearch, import the client from `algoliasearch/lite` (smaller bundle and
|
|
29
31
|
correct types — see `instantsearch-setup.md`).
|
|
30
32
|
- 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.
|
|
33
|
+
- Vendor CSS has to be **global** CSS: import an InstantSearch theme from a JS/TS module
|
|
34
|
+
or the app's global stylesheet, and keep `.ais-*` overrides in a global block. Inside a
|
|
35
|
+
framework-scoped style block (Astro `<style>`, Vue `<style scoped>`, Svelte `<style>`) or
|
|
36
|
+
a CSS Module, scoping rewrites the selectors, the widget's runtime DOM never carries the
|
|
37
|
+
scope attribute, and the styling silently does nothing while the build still passes.
|
|
31
38
|
|
|
32
39
|
## Files
|
|
33
40
|
|
|
@@ -20,10 +20,19 @@ 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 already manages focus, blur,
|
|
24
|
+
outside-click dismissal, Up/Down arrow navigation with wrap-around, Enter/Escape/Tab, and
|
|
25
|
+
the combobox ARIA (`combobox`, `aria-expanded`, `aria-activedescendant`), so do not add
|
|
26
|
+
your own open/close logic or ARIA roles around it.
|
|
27
|
+
|
|
28
|
+
The `autocomplete` export needs `instantsearch.js` **4.110.0 or newer**. If the project
|
|
29
|
+
pins an older 4.x, upgrade within the major (`^4`) rather than reaching for a different
|
|
30
|
+
widget.
|
|
31
|
+
|
|
23
32
|
```js
|
|
24
33
|
import { liteClient as algoliasearch } from 'algoliasearch/lite'
|
|
25
34
|
import instantsearch from 'instantsearch.js'
|
|
26
|
-
import {
|
|
35
|
+
import { autocomplete } from 'instantsearch.js/es/widgets'
|
|
27
36
|
import {
|
|
28
37
|
ALGOLIA_APP_ID,
|
|
29
38
|
ALGOLIA_SEARCH_API_KEY,
|
|
@@ -39,24 +48,159 @@ const search = instantsearch({
|
|
|
39
48
|
})
|
|
40
49
|
|
|
41
50
|
search.addWidgets([
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
autocomplete({
|
|
52
|
+
container: '#autocomplete',
|
|
53
|
+
placeholder: 'Search...',
|
|
54
|
+
indices: [
|
|
55
|
+
{
|
|
56
|
+
indexName: ALGOLIA_INDEX_NAME,
|
|
57
|
+
getURL: (item) => `/products/${item.handle}`,
|
|
58
|
+
templates: {
|
|
59
|
+
item({ item, onSelect }, { html, components }) {
|
|
60
|
+
return html`<div onClick=${onSelect}>
|
|
61
|
+
${components.Highlight({ hit: item, attribute: 'name' })}
|
|
62
|
+
</div>`
|
|
63
|
+
},
|
|
64
|
+
noResults(_, { html }) {
|
|
65
|
+
return html`<span>No results</span>`
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
}),
|
|
44
71
|
])
|
|
45
72
|
|
|
46
73
|
search.start()
|
|
47
74
|
```
|
|
48
75
|
|
|
76
|
+
### Navigation is `getURL`'s job, not the template's
|
|
77
|
+
|
|
78
|
+
Where the hits have a detail page, pass `indices[].getURL` — `(item) => string`, a route
|
|
79
|
+
that already exists; don't invent one. The widget then navigates on click **and** on Enter
|
|
80
|
+
over a keyboard-highlighted item, and records the click event on the way out. Without it,
|
|
81
|
+
Enter on a highlighted result does nothing.
|
|
82
|
+
|
|
83
|
+
The `item` template renders content only: **no `<a>`, no `href`, no click handler of your
|
|
84
|
+
own, and never `stopPropagation`.** The widget's own `<li>` owns the click — it sends the
|
|
85
|
+
Insights `click` event, closes the panel, and clears the active option. Wrapping the
|
|
86
|
+
template in an anchor swallows all three: the link still navigates, so it looks correct,
|
|
87
|
+
while click analytics never fire and the panel stays open behind the new page.
|
|
88
|
+
|
|
89
|
+
Give every index a `templates.noResults` — without it the panel renders an empty box on a
|
|
90
|
+
query that matches nothing.
|
|
91
|
+
|
|
92
|
+
`getSearchPageURL` sends Enter with no result highlighted to a full search-results page;
|
|
93
|
+
pass it only if the app has one. `.getQuery` is optional. Leave `showQuerySuggestions`,
|
|
94
|
+
`showPromptSuggestions`, `showRecent`, and `aiMode` unset — each depends on an index or
|
|
95
|
+
feature (query suggestions, recent-search storage, AI chat) this project doesn't have.
|
|
96
|
+
|
|
97
|
+
In TypeScript, type the records through the widget's generic instead of casting inside the
|
|
98
|
+
templates:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import type { Hit } from 'instantsearch.js'
|
|
102
|
+
|
|
103
|
+
type ProductHit = Hit<{ title: string; handle: string }>
|
|
104
|
+
|
|
105
|
+
autocomplete<ProductHit>({
|
|
106
|
+
/* item, getURL, and the templates are all typed from ProductHit */
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Styling the panel
|
|
111
|
+
|
|
112
|
+
The widget ships no CSS of its own. Install `instantsearch.css` and import a theme once,
|
|
113
|
+
from the component's own JS/TS module or from the app's global stylesheet:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
import 'instantsearch.css/themes/satellite.css'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Never import the theme as a CSS `@import` inside a framework-scoped style block** — an
|
|
120
|
+
Astro `<style>`, a Vue `<style scoped>`, a Svelte `<style>`, a CSS Module. Scoping
|
|
121
|
+
rewrites every selector in the imported theme
|
|
122
|
+
(`.ais-AutocompleteInput[data-astro-cid-abc123]`), and the widget renders its own DOM at
|
|
123
|
+
runtime into an empty container, so none of those elements carry the scope attribute and
|
|
124
|
+
not one theme rule matches. Nothing errors, the build passes, and the search UI renders
|
|
125
|
+
unstyled. Your own overrides of `.ais-*` classes have the same problem: put them in global
|
|
126
|
+
CSS (`<style is:global>` in Astro, an unscoped block or `:deep()` in Vue).
|
|
127
|
+
|
|
128
|
+
The theme already ships a dark palette behind `:root[data-theme=dark], .dark`. In a dark
|
|
129
|
+
app, set `data-theme="dark"` on `<html>` or render the widget under a `.dark` ancestor and
|
|
130
|
+
adjust from there — don't redefine the light palette by hand.
|
|
131
|
+
|
|
132
|
+
The theme defines its palette as `--ais-*` custom properties on `:root`, and the colors
|
|
133
|
+
are **RGB triplets consumed through `rgba()`**, not CSS colors — override them with
|
|
134
|
+
triplets or every widget color breaks:
|
|
135
|
+
|
|
136
|
+
```css
|
|
137
|
+
:root {
|
|
138
|
+
--ais-background-color-rgb: 24, 24, 27; /* not #18181b */
|
|
139
|
+
--ais-text-color-rgb: 244, 244, 245;
|
|
140
|
+
--ais-primary-color-rgb: 129, 140, 248;
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Everything else is plain CSS against the widget's own classes, or `cssClasses` /
|
|
145
|
+
`indices[].cssClasses` to add your own class names to the same elements. Know which
|
|
146
|
+
element owns what before writing a rule:
|
|
147
|
+
|
|
148
|
+
- `.ais-Autocomplete` — root. Its **container** sets the widget's width; don't try to size
|
|
149
|
+
the widget from the input.
|
|
150
|
+
- `.ais-AutocompleteForm` — the visible frame: background, border, radius, and the focus
|
|
151
|
+
ring (`:focus-within`). Style the input's pill here.
|
|
152
|
+
- `.ais-AutocompleteInput` — text only. The theme gives it `border: 0`,
|
|
153
|
+
`background: none`, `width: 100%`. A border or a fixed width here leaves the form's own
|
|
154
|
+
frame drawn around it and changes no widths.
|
|
155
|
+
- `.ais-AutocompletePanel` (+ `--open`) and `.ais-AutocompletePanelLayout` — dropdown
|
|
156
|
+
surface and its scroll container.
|
|
157
|
+
- `.ais-AutocompleteIndex`, `-IndexHeader`, `-IndexList`, `-IndexItem`, `-IndexNoResults`
|
|
158
|
+
— one index's results. The active item carries `aria-selected="true"`.
|
|
159
|
+
- `.ais-AutocompleteItemWrapper`, `-ItemContent`, `-ItemIcon` — inside one result.
|
|
160
|
+
- `.ais-AutocompleteSubmitButton`, `-ClearButton`, `-LoadingIndicator`, `-Label` — the
|
|
161
|
+
form's own controls.
|
|
162
|
+
- `.ais-AutocompleteDetached*` — detached mode (below).
|
|
163
|
+
|
|
164
|
+
Don't style a class that isn't in that list: `.ais-SearchBox-*` belongs to the `searchBox`
|
|
165
|
+
widget and does nothing here. To check the names against the installed version:
|
|
166
|
+
`grep -o 'ais-Autocomplete[A-Za-z-]*' node_modules/instantsearch.css/themes/satellite.css | sort -u`.
|
|
167
|
+
|
|
168
|
+
The theme's panel is `position: absolute; width: 100%`, so it inherits the width of the
|
|
169
|
+
input container — a small input yields an unreadably narrow panel. Give it a minimum
|
|
170
|
+
width and anchor it to the edge it opens from, or it grows off-screen:
|
|
171
|
+
|
|
172
|
+
```css
|
|
173
|
+
.ais-AutocompletePanel {
|
|
174
|
+
min-width: 320px;
|
|
175
|
+
left: 0; /* right: 0; left: auto for an input aligned to the right */
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Below `(max-width: 680px)` the widget switches to detached (fullscreen) mode: the inline
|
|
180
|
+
form is replaced by a `.ais-AutocompleteDetachedSearchButton`, and the form and results
|
|
181
|
+
move into an overlay (`.ais-AutocompleteDetachedOverlay`,
|
|
182
|
+
`.ais-AutocompleteDetachedContainer`, `.ais-AutocompleteDetachedFormContainer`). So panel
|
|
183
|
+
width rules only apply above that breakpoint, and styling the inline form leaves the
|
|
184
|
+
mobile trigger button on the theme's own look unless you style it too. Change the
|
|
185
|
+
breakpoint with `detachedMediaQuery` or `--ais-autocomplete-detached-media-query`; pass
|
|
186
|
+
`''` to disable detached mode and keep the inline form at every width.
|
|
187
|
+
|
|
49
188
|
## Insights (click analytics) — always enable it
|
|
50
189
|
|
|
51
190
|
The `insights` option turns on the Insights middleware (`instantsearch.js` v4.55+),
|
|
52
191
|
which adds `clickAnalytics: true` to every query and keeps the `userToken` in sync
|
|
53
192
|
between search and event calls. Never set `clickAnalytics` or `userToken` on the `configure` widget by hand.
|
|
54
193
|
|
|
55
|
-
Use `insights: true
|
|
56
|
-
itself; do not install the package
|
|
194
|
+
Use `insights: true` and nothing else. The middleware loads `search-insights` from the
|
|
195
|
+
jsDelivr CDN itself; do not install the package, do not pass `insightsClient`, and do not
|
|
196
|
+
declare `window.aa`.
|
|
197
|
+
|
|
198
|
+
Everything below this line is a **conditional fallback**. Apply one only when its stated
|
|
199
|
+
condition actually holds in this project — a CSP you can point at, a user id the app
|
|
200
|
+
already has. Where it doesn't hold, `insights: true` is the whole implementation.
|
|
57
201
|
|
|
58
|
-
If a Content-Security-Policy blocks jsDelivr, the CDN script cannot
|
|
59
|
-
case, install `search-insights` from npm and pass its default export as
|
|
202
|
+
If the project has a Content-Security-Policy that blocks jsDelivr, the CDN script cannot
|
|
203
|
+
load. Only in that case, install `search-insights` from npm and pass its default export as
|
|
60
204
|
`insightsClient` in the `insights` option:
|
|
61
205
|
|
|
62
206
|
```js
|
|
@@ -90,27 +234,19 @@ insights: {
|
|
|
90
234
|
|
|
91
235
|
### Click and conversion events
|
|
92
236
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
237
|
+
With `insights: true`, the `autocomplete` widget sends `view` and `click` events itself:
|
|
238
|
+
`view` when results render, `click` from the result element's own click handler. Its item
|
|
239
|
+
template receives `{ item, onSelect }` and **no `sendEvent`** — there is nothing to wire,
|
|
240
|
+
and nothing to send events from. Call `onSelect` for the widget's own selection behavior
|
|
241
|
+
(closing the panel, recording a recent search); the click event fires either way.
|
|
96
242
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
```
|
|
243
|
+
The `hits` and `infiniteHits` widgets behave the same way. Only a custom `item` template
|
|
244
|
+
there needs explicit wiring, and only when its click target is your own element — that
|
|
245
|
+
template gets `sendEvent` as the second argument.
|
|
111
246
|
|
|
112
|
-
Conversions are business actions (add to cart, signup, booking)
|
|
113
|
-
real action,
|
|
247
|
+
Conversions are business actions (add to cart, signup, booking) that happen away from the
|
|
248
|
+
results panel, so wire them at the real action, in a component that has a `sendEvent` of
|
|
249
|
+
its own:
|
|
114
250
|
|
|
115
251
|
```js
|
|
116
252
|
sendEvent('conversion', hit, 'Product Added To Cart', {
|
|
@@ -20,9 +20,18 @@ 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 already manages focus, blur,
|
|
24
|
+
outside-click dismissal, Up/Down arrow navigation with wrap-around, Enter/Escape/Tab, and
|
|
25
|
+
the combobox ARIA (`combobox` on the input, `aria-expanded`, `aria-activedescendant`), so
|
|
26
|
+
do not add your own open/close logic or ARIA roles around it.
|
|
27
|
+
|
|
28
|
+
The `Autocomplete` export needs `react-instantsearch` **7.18.0 or newer**. If the project
|
|
29
|
+
pins an older 7.x, upgrade within the major (`^7`) rather than reaching for a different
|
|
30
|
+
widget.
|
|
31
|
+
|
|
23
32
|
```tsx
|
|
24
33
|
import { liteClient as algoliasearch } from 'algoliasearch/lite'
|
|
25
|
-
import { InstantSearch,
|
|
34
|
+
import { InstantSearch, Autocomplete } from 'react-instantsearch'
|
|
26
35
|
import {
|
|
27
36
|
ALGOLIA_APP_ID,
|
|
28
37
|
ALGOLIA_SEARCH_API_KEY,
|
|
@@ -38,13 +47,137 @@ export function Search() {
|
|
|
38
47
|
indexName={ALGOLIA_INDEX_NAME}
|
|
39
48
|
insights={true}
|
|
40
49
|
>
|
|
41
|
-
<
|
|
42
|
-
|
|
50
|
+
<Autocomplete
|
|
51
|
+
placeholder="Search..."
|
|
52
|
+
indices={[
|
|
53
|
+
{
|
|
54
|
+
indexName: ALGOLIA_INDEX_NAME,
|
|
55
|
+
getURL: (item) => `/products/${item.handle}`,
|
|
56
|
+
itemComponent: ({ item, onSelect }) => (
|
|
57
|
+
<div onClick={onSelect}>{item.name}</div>
|
|
58
|
+
),
|
|
59
|
+
noResultsComponent: () => <span>No results</span>,
|
|
60
|
+
},
|
|
61
|
+
]}
|
|
62
|
+
/>
|
|
43
63
|
</InstantSearch>
|
|
44
64
|
)
|
|
45
65
|
}
|
|
46
66
|
```
|
|
47
67
|
|
|
68
|
+
### Navigation is `getURL`'s job, not the item component's
|
|
69
|
+
|
|
70
|
+
Where the hits have a detail page, pass `indices[].getURL` — `(item) => string`, a route
|
|
71
|
+
that already exists; don't invent one. The widget then navigates on click **and** on Enter
|
|
72
|
+
over a keyboard-highlighted item, and records the click event on the way out. Without it,
|
|
73
|
+
Enter on a highlighted result does nothing.
|
|
74
|
+
|
|
75
|
+
`itemComponent` renders content only: **no `<a>`, no `href`, no `onClick` of your own
|
|
76
|
+
beyond the `onSelect` it is handed, and never `stopPropagation`.** The widget's own `<li>`
|
|
77
|
+
owns the click — it sends the Insights `click` event, closes the panel, and clears the
|
|
78
|
+
active option. Wrapping the item in a link or a router `<Link>` swallows all three: the
|
|
79
|
+
navigation still happens, so it looks correct, while click analytics never fire and the
|
|
80
|
+
panel stays open behind the new page.
|
|
81
|
+
|
|
82
|
+
Give every index a `noResultsComponent` — without it the panel renders an empty box on a
|
|
83
|
+
query that matches nothing.
|
|
84
|
+
|
|
85
|
+
`getSearchPageURL` sends Enter with no result highlighted to a full search-results page;
|
|
86
|
+
pass it only if the app has one. `.getQuery` is optional. Leave `showQuerySuggestions`,
|
|
87
|
+
`showPromptSuggestions`, `showRecent`, and `aiMode` unset — each depends on an index or
|
|
88
|
+
feature (query suggestions, recent-search storage, AI chat) this project doesn't have.
|
|
89
|
+
|
|
90
|
+
In TypeScript, type the records through the component's generic instead of casting inside
|
|
91
|
+
`itemComponent`:
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
import type { Hit } from 'instantsearch.js'
|
|
95
|
+
|
|
96
|
+
type ProductHit = Hit<{ title: string; handle: string }>
|
|
97
|
+
|
|
98
|
+
export function Search() {
|
|
99
|
+
// item, getURL, and itemComponent are all typed from ProductHit
|
|
100
|
+
return <Autocomplete<ProductHit> indices={[]} />
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Styling the panel
|
|
105
|
+
|
|
106
|
+
The widget ships no CSS of its own. Install `instantsearch.css` and import a theme once,
|
|
107
|
+
from the component module or the app's global stylesheet:
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
import 'instantsearch.css/themes/satellite.css'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Never route the theme through scoped or module CSS** — a CSS Module, an Astro `<style>`,
|
|
114
|
+
a Vue `<style scoped>`, a Svelte `<style>`, or any styled-components-style wrapper that
|
|
115
|
+
rewrites selectors. Scoping renames every selector in the imported theme, and the widget
|
|
116
|
+
renders its own DOM at runtime, so none of its elements match the rewritten names. Nothing
|
|
117
|
+
errors, the build passes, and the search UI renders unstyled. Your own overrides of
|
|
118
|
+
`.ais-*` classes have the same problem: keep them in a global stylesheet, or add your own
|
|
119
|
+
class names through `classNames` and style those instead.
|
|
120
|
+
|
|
121
|
+
The theme already ships a dark palette behind `:root[data-theme=dark], .dark`. In a dark
|
|
122
|
+
app, set `data-theme="dark"` on `<html>` or render the widget under a `.dark` ancestor and
|
|
123
|
+
adjust from there — don't redefine the light palette by hand.
|
|
124
|
+
|
|
125
|
+
The theme defines its palette as `--ais-*` custom properties on `:root`, and the colors
|
|
126
|
+
are **RGB triplets consumed through `rgba()`**, not CSS colors — override them with
|
|
127
|
+
triplets or every widget color breaks:
|
|
128
|
+
|
|
129
|
+
```css
|
|
130
|
+
:root {
|
|
131
|
+
--ais-background-color-rgb: 24, 24, 27; /* not #18181b */
|
|
132
|
+
--ais-text-color-rgb: 244, 244, 245;
|
|
133
|
+
--ais-primary-color-rgb: 129, 140, 248;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Everything else is plain CSS against the widget's own classes, or the `classNames` /
|
|
138
|
+
`indices[].classNames` props to add your own class names to the same elements. Know which
|
|
139
|
+
element owns what before writing a rule:
|
|
140
|
+
|
|
141
|
+
- `.ais-Autocomplete` — root. Its **container** sets the widget's width; don't try to size
|
|
142
|
+
the widget from the input.
|
|
143
|
+
- `.ais-AutocompleteForm` — the visible frame: background, border, radius, and the focus
|
|
144
|
+
ring (`:focus-within`). Style the input's pill here.
|
|
145
|
+
- `.ais-AutocompleteInput` — text only. The theme gives it `border: 0`,
|
|
146
|
+
`background: none`, `width: 100%`. A border or a fixed width here leaves the form's own
|
|
147
|
+
frame drawn around it and changes no widths.
|
|
148
|
+
- `.ais-AutocompletePanel` (+ `--open`) and `.ais-AutocompletePanelLayout` — dropdown
|
|
149
|
+
surface and its scroll container.
|
|
150
|
+
- `.ais-AutocompleteIndex`, `-IndexHeader`, `-IndexList`, `-IndexItem`, `-IndexNoResults`
|
|
151
|
+
— one index's results. The active item carries `aria-selected="true"`.
|
|
152
|
+
- `.ais-AutocompleteItemWrapper`, `-ItemContent`, `-ItemIcon` — inside one result.
|
|
153
|
+
- `.ais-AutocompleteSubmitButton`, `-ClearButton`, `-LoadingIndicator`, `-Label` — the
|
|
154
|
+
form's own controls.
|
|
155
|
+
- `.ais-AutocompleteDetached*` — detached mode (below).
|
|
156
|
+
|
|
157
|
+
Don't style a class that isn't in that list: `.ais-SearchBox-*` belongs to the `SearchBox`
|
|
158
|
+
widget and does nothing here. To check the names against the installed version:
|
|
159
|
+
`grep -o 'ais-Autocomplete[A-Za-z-]*' node_modules/instantsearch.css/themes/satellite.css | sort -u`.
|
|
160
|
+
|
|
161
|
+
The theme's panel is `position: absolute; width: 100%`, so it inherits the width of the
|
|
162
|
+
input container — a small input yields an unreadably narrow panel. Give it a minimum
|
|
163
|
+
width and anchor it to the edge it opens from, or it grows off-screen:
|
|
164
|
+
|
|
165
|
+
```css
|
|
166
|
+
.ais-AutocompletePanel {
|
|
167
|
+
min-width: 320px;
|
|
168
|
+
left: 0; /* right: 0; left: auto for an input aligned to the right */
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Below `(max-width: 680px)` the widget switches to detached (fullscreen) mode: the inline
|
|
173
|
+
form is replaced by a `.ais-AutocompleteDetachedSearchButton`, and the form and results
|
|
174
|
+
move into an overlay (`.ais-AutocompleteDetachedOverlay`,
|
|
175
|
+
`.ais-AutocompleteDetachedContainer`, `.ais-AutocompleteDetachedFormContainer`). So panel
|
|
176
|
+
width rules only apply above that breakpoint, and styling the inline form leaves the
|
|
177
|
+
mobile trigger button on the theme's own look unless you style it too. Change the
|
|
178
|
+
breakpoint with `detachedMediaQuery` or `--ais-autocomplete-detached-media-query`; pass
|
|
179
|
+
`''` to disable detached mode and keep the inline form at every width.
|
|
180
|
+
|
|
48
181
|
Do not inline `searchClient={algoliasearch(...)}` — keep the stable reference above.
|
|
49
182
|
|
|
50
183
|
## Insights (click analytics) — always enable it
|
|
@@ -53,17 +186,29 @@ The `insights` prop turns on the Insights middleware, which adds
|
|
|
53
186
|
`clickAnalytics: true` to every query and keeps the `userToken` in sync between search
|
|
54
187
|
and event calls. Never set `clickAnalytics` or `userToken` on `<Configure>` by hand.
|
|
55
188
|
|
|
56
|
-
Use `insights={true}
|
|
57
|
-
itself; do not install the package
|
|
189
|
+
Use `insights={true}` and nothing else. The middleware loads `search-insights` from the
|
|
190
|
+
jsDelivr CDN itself; do not install the package, do not pass `insightsClient`, and do not
|
|
191
|
+
declare `window.aa`.
|
|
192
|
+
|
|
193
|
+
Everything below this line is a **conditional fallback**. Apply one only when its stated
|
|
194
|
+
condition actually holds in this project — a CSP you can point at, a user id the app
|
|
195
|
+
already has. Where it doesn't hold, `insights={true}` is the whole implementation.
|
|
58
196
|
|
|
59
|
-
If a Content-Security-Policy blocks jsDelivr, the CDN script cannot
|
|
197
|
+
If the project has a Content-Security-Policy that blocks jsDelivr, the CDN script cannot
|
|
198
|
+
load. In that
|
|
60
199
|
case, install `search-insights` from npm and pass its default export as
|
|
61
200
|
`insightsClient`:
|
|
62
201
|
|
|
63
202
|
```tsx
|
|
64
203
|
import aa from 'search-insights'
|
|
65
204
|
|
|
66
|
-
|
|
205
|
+
export function Search() {
|
|
206
|
+
return (
|
|
207
|
+
<InstantSearch insights={{ insightsClient: aa }}>
|
|
208
|
+
{/* widgets */}
|
|
209
|
+
</InstantSearch>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
67
212
|
```
|
|
68
213
|
|
|
69
214
|
The middleware calls `aa('init', ...)` itself with the app ID and search key, so no
|
|
@@ -83,8 +228,14 @@ policy allows it:
|
|
|
83
228
|
|
|
84
229
|
### Click and conversion events
|
|
85
230
|
|
|
86
|
-
|
|
87
|
-
|
|
231
|
+
With `insights={true}`, `<Autocomplete>` sends `view` and `click` events itself: `view`
|
|
232
|
+
when results render, `click` from the result element's own click handler. Its
|
|
233
|
+
`itemComponent` receives `{ item, onSelect, onApply }` and **no `sendEvent`** — there is
|
|
234
|
+
nothing to wire, and nothing to send events from. Call `onSelect` for the widget's own
|
|
235
|
+
selection behavior (closing the panel, recording a recent search); the click event fires
|
|
236
|
+
either way.
|
|
237
|
+
|
|
238
|
+
`<Hits>` and `<InfiniteHits>` behave the same way. Two cases there need explicit wiring:
|
|
88
239
|
|
|
89
240
|
- A custom `hitComponent` whose click target is your own element — it receives a
|
|
90
241
|
`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. `ais-autocomplete` is headless: its default
|
|
24
|
+
slot only provides `currentRefinement`, `indices`, and `refine` — it renders nothing and
|
|
25
|
+
handles no focus, blur, keyboard, or ARIA itself. You must build all of it: keep the panel
|
|
26
|
+
closed until the input is focused and has a query, close it on Escape, blur, or an outside
|
|
27
|
+
click, move an active-option highlight with the Up/Down arrows and select it with Enter,
|
|
28
|
+
carry the combobox ARIA on the input, and send click events via 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,73 @@ 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"` belongs on the `input`, not on a wrapper — with `aria-controls`
|
|
137
|
+
pointing at the popup's `id` and `aria-activedescendant` at the active option's `id`.
|
|
138
|
+
Keep focus on the input while the arrows move the highlight.
|
|
139
|
+
- The element carrying `role="listbox"` must be the direct parent of the
|
|
140
|
+
`role="option"` elements. A `ul` nested inside a `listbox` div, or options nested one
|
|
141
|
+
level deeper, breaks the accessibility tree.
|
|
142
|
+
- `v-show`, not `v-if`, so the `aria-activedescendant` target stays in the DOM.
|
|
143
|
+
- `@mousedown.prevent` on an option, not `@click` — selection has to land before
|
|
144
|
+
`focusout` closes the panel.
|
|
145
|
+
- `focusout` on the wrapper is the outside-click dismissal: `event.relatedTarget` is
|
|
146
|
+
`null` for a click on non-focusable page chrome, so the panel closes. Don't add a
|
|
147
|
+
`document` click listener on top — it fights `@mousedown.prevent` on the options.
|
|
148
|
+
- `.is-active` needs a visible style, not just `aria-selected`: the panel is the only
|
|
149
|
+
focus feedback a keyboard user gets, since focus never leaves the input.
|
|
150
|
+
|
|
151
|
+
The panel is your own markup here, so it has no widget theme to inherit from: give
|
|
152
|
+
`.search-panel` its own background from the app's theme tokens (never transparent over
|
|
153
|
+
page content), text that clears 4.5:1 against that background, `position: absolute` with
|
|
154
|
+
`min-width: 320px` anchored to the input edge it opens from, and a `max-height` with
|
|
155
|
+
`overflow-y: auto`.
|
|
156
|
+
|
|
157
|
+
`ais-autocomplete`'s `indices` slot prop supports federated search across multiple
|
|
158
|
+
indices; this project has only one, so `indices[0]` is always the one to read.
|
|
159
|
+
|
|
47
160
|
Register the widgets via the InstantSearch Vue plugin in your app entry:
|
|
48
161
|
|
|
49
162
|
```js
|
|
@@ -57,10 +170,14 @@ The `insights` prop turns on the Insights middleware, which adds
|
|
|
57
170
|
`clickAnalytics: true` to every query and keeps the `userToken` in sync between search
|
|
58
171
|
and event calls. Never set `clickAnalytics` or `userToken` on `ais-configure` by hand.
|
|
59
172
|
|
|
60
|
-
Use `:insights="true"
|
|
61
|
-
the package
|
|
173
|
+
Use `:insights="true"` and nothing else. The middleware loads `search-insights` from the jsDelivr CDN
|
|
174
|
+
itself; do not install the package, do not pass `insightsClient`, and do not declare `window.aa`.
|
|
62
175
|
|
|
63
|
-
|
|
176
|
+
Everything below this line is a **conditional fallback**. Apply one only when its stated condition
|
|
177
|
+
actually holds in this project — a CSP you can point at, a user id the app already has. Where it
|
|
178
|
+
doesn't hold, `:insights="true"` is the whole implementation.
|
|
179
|
+
|
|
180
|
+
If the project has a Content-Security-Policy that blocks jsDelivr, the CDN script cannot load. In that
|
|
64
181
|
case, install `search-insights` from npm and pass its default export as
|
|
65
182
|
`insightsClient`:
|
|
66
183
|
|
|
@@ -85,19 +202,18 @@ const insights = { insightsInitParams: { useCookie: true } } // then :insights="
|
|
|
85
202
|
|
|
86
203
|
### Click and conversion events
|
|
87
204
|
|
|
88
|
-
`ais-
|
|
89
|
-
|
|
205
|
+
`ais-autocomplete` wraps the same connector as the other flavors, so `view` events fire
|
|
206
|
+
on their own when results render. `click` does not, because the option markup is yours:
|
|
207
|
+
call the index's `sendEvent` from your own option handler, as in the setup example above.
|
|
90
208
|
|
|
91
209
|
```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>
|
|
210
|
+
<li @mousedown.prevent="indices[0].sendEvent('click', hit, 'Hit Clicked')">
|
|
99
211
|
```
|
|
100
212
|
|
|
213
|
+
`ais-hits`, if the app also has a full results page, does send `view` and `click` itself.
|
|
214
|
+
Only a custom `item` slot there needs wiring, and only when its click target is your own
|
|
215
|
+
element — that slot provides `sendEvent`.
|
|
216
|
+
|
|
101
217
|
Conversions are business actions (add to cart, signup, booking), so wire them at the
|
|
102
218
|
real action, not at render:
|
|
103
219
|
|