├── .editorconfig
├── .eslintrc.json
├── .github
└── workflows
│ ├── CI.yaml
│ └── coverage.yaml
├── .gitignore
├── API-Style.md
├── CONTRIBUTING.md
├── LICENSE.txt
├── README.md
├── assets
├── eg-docsearch301-ajax.png
├── eg-minibar132-ajax.png
├── icon-close.svg
├── icon-input.svg
├── logo-text-dark.svg
├── logo-text.svg
└── logo.svg
├── demo
├── compare--docsearch-2.html
├── compare--docsearch-3.html
├── demo-theme.css
├── demo.css
└── index.html
├── index.html
├── karma.conf.js
├── package.json
├── test
├── index.html
└── test.js
├── typesense-minibar-foot.css
├── typesense-minibar.css
└── typesense-minibar.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": "semistandard",
4 | "env": {
5 | "es2017": true,
6 | "browser": true
7 | },
8 | "rules": {
9 | "comma-dangle": "off"
10 | },
11 | "globals": {
12 | "tsminibar": "readonly"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.github/workflows/CI.yaml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | - push
4 | - pull_request
5 |
6 | jobs:
7 | test:
8 | # Includes firefox, chromium, and Node.js 18.
9 | # https://github.com/actions/runner-images/blob/ubuntu22/20240403.1/images/ubuntu/Ubuntu2204-Readme.md
10 | runs-on: ubuntu-22.04
11 | env:
12 | FORCE_COLOR: "1"
13 | steps:
14 | - uses: actions/checkout@v4
15 | - run: npm install
16 | - run: npm test
17 |
--------------------------------------------------------------------------------
/.github/workflows/coverage.yaml:
--------------------------------------------------------------------------------
1 | name: Publish
2 | on:
3 | push:
4 | branches:
5 | - main
6 | workflow_dispatch:
7 |
8 | # Set GITHUB_TOKEN
9 | permissions:
10 | contents: write
11 |
12 | concurrency:
13 | group: 'pages'
14 | cancel-in-progress: true
15 |
16 | jobs:
17 | deploy:
18 | # Includes firefox, chromium, and Node.js 18.
19 | # https://github.com/actions/runner-images/blob/ubuntu22/20240403.1/images/ubuntu/Ubuntu2204-Readme.md
20 | runs-on: ubuntu-22.04
21 | env:
22 | FORCE_COLOR: "1"
23 | steps:
24 | - uses: actions/checkout@v4
25 | - run: npm install
26 |
27 | - name: Generate coverage
28 | run: npm test
29 |
30 | - name: Deploy
31 | env:
32 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
33 | DEPLOY_PREP: /tmp/deployment-prep
34 | DEPLOY_BRANCH: gh-pages
35 | run: |
36 | mkdir -p "${DEPLOY_PREP}" && cd "${DEPLOY_PREP}"
37 | # Clone and checkout existing branch, or initialise with a new and empty branch
38 | git clone --depth 5 --branch "${DEPLOY_BRANCH}" "https://github.com/${GITHUB_REPOSITORY}.git" . || git init -b "${DEPLOY_BRANCH}"
39 | # Snapshot
40 | cp -R $GITHUB_WORKSPACE/{*.*,assets,coverage,demo,test} .
41 | # Modifications
42 | touch .nojekyll
43 | # Push
44 | git config user.name "${GITHUB_ACTOR}" && \
45 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" && \
46 | git add . && \
47 | git commit --allow-empty -m "Build commit ${GITHUB_SHA}" && \
48 | git push "https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${DEPLOY_BRANCH}"
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.nyc_output/
2 | /coverage/
3 | /node_modules/
4 | /.eslintcache
5 | /package-lock.json
6 |
--------------------------------------------------------------------------------
/API-Style.md:
--------------------------------------------------------------------------------
1 | [< Back to README](./README.md)
2 |
3 | # Style API
4 |
5 | We support two approaches to styling.
6 |
7 | ## CSS variables
8 |
9 | You can **override CSS variables** that we expose, and change them to your preferred value.
10 |
11 | This works best for making minor changes, such as changing the link and
12 | highlight color, or creating a dark mode. Copy or import the [typesense-minibar.css](./typesense-minibar.css)
13 | stylesheet, then override any of the below variables from your own stylesheet.
14 |
15 | Note: Set variables on the `.tsmb-form` or `typesense-minibar` selector only. The variables are
16 | automatically picked up by the internal more specific selectors.
17 | You don't have to write any custom CSS rules!
18 |
19 | Text sizes:
20 | * `--tsmb-size-base`: Size of input text, group headings, and major whitespace.
21 | * `--tsmb-size-sm`: Size of search results (title and excerpt), icon text (slash), and minor whitespace.
22 | * `--tsmb-size-input`: Height of input field.
23 |
24 | Misc sizes:
25 | * `--tsmb-size-edge`: Line thickness of edges, e.g. border-width.
26 | * `--tsmb-size-radius`: Roundness, e.g. border-radius.
27 | * `--tsmb-size-highlight`: Line thickness of cursor highlight, e.g. border-left-width.
28 | * `--tsmb-size-listbox-width`: The maximum width of the listbox. The minimum is always the width of the input field.
29 | * `--tsmb-size-listbox-max-height`: The maximum height of the listbox.
30 | * `--tsmb-size-listbox-right`: Set to `0` to create a right-aligned listbox that expands to the left.
31 |
32 | Base layout, for idle or inactive input field:
33 | * `--tsmb-color-base-background`: Background color, e.g. white in lightmode.
34 | * `--tsmb-color-base30`: Hard constrast (for input text), e.g. dark grey in lightmode.
35 | * `--tsmb-color-base50`: Medium contrast (for input placeholder), e.g. medium grey in lightmode.
36 | * `--tsmb-color-base90`: Subtle contrast (for lines, slash icon), e.g. light pastel in lightmode.
37 |
38 | Active layout, for active input field and result box. Defaults to the same colors as above.
39 | * `--tsmb-color-focus-background`: Background color, e.g. white in lightmode.
40 | * `--tsmb-color-focus30`: Hard contrast (focussed input text).
41 | * `--tsmb-color-focus50`: Medium contrast (for search result excerpt, focussed placeholder, footer).
42 | * `--tsmb-color-focus90`: Subtle contrast (for result borders).
43 |
44 | Primary colors, by default only used in the active layout:
45 | * `--tsmb-color-primary30`: Hard contrast, for colorful dark background or dark text.
46 | * `--tsmb-color-primary50`: Medium contrast, for colorful links or buttons.
47 | * `--tsmb-color-primary90`: Subtle contrast, for selection background.
48 |
49 | Example (Web Component):
50 |
51 | ```css
52 | /* Dark theme for inactive input field. */
53 | typesense-minibar {
54 | --tsmb-color-base-background: #691c69;
55 | --tsmb-color-base30: #390f39;
56 | --tsmb-color-base50: #c090c0;
57 | --tsmb-color-base90: #c090c0;
58 | }
59 | ```
60 |
61 | Example (class):
62 |
63 | ```css
64 | /* Dark theme for inactive input field. */
65 | .tsmb-form {
66 | --tsmb-color-base-background: #691c69;
67 | --tsmb-color-base30: #390f39;
68 | --tsmb-color-base50: #c090c0;
69 | --tsmb-color-base90: #c090c0;
70 | }
71 | ```
72 |
73 | ## Semantic HTML
74 |
75 | Alternatively, you can opt-out of the accompanying stylesheet and make your own!
76 | In that case, we recommend you target the documented selectors below.
77 |
78 | | Selector | Description
79 | | --- | :---
80 | | `.tsmb-form--slash …` | Pressing a slash will activate this form. Use as basis for longer selectors.
This selector matches after the JavaScript code has run, unless configured with `data-slash=false`). This ensures you can safely use it to create a slash icon, and trust that it won't be displayed if JavaScript failed, was unsupported, or was disabled.
**Example**: `.tsmb-form--slash:not(:focus-within)::after { content: '/'; }`
81 | | `.tsmb-form--open` | The form currently has an open result box.
82 | | `.tsmb-form[data-group=true] …`
`typesense-minibar[data-group=true] …` | Override styled when using grouped results. Use as basis for longer selectors.
83 | | `.tsmb-form input[type=search]`
`typesense-minibar [role=listbox]` | Dropdown menu, populated with either one or more search results, or `.tsmb-empty`. When the results are closed, this element is hidden by setting the native `hidden` attribute.
85 | | `.tsmb-form [role=option]`
`typesense-minibar [role=option] a` | Clickable portion of result (title + content). This covers the full search result, except when results are grouped, then the first result in a group additionally contains `.tsmb-suggestion_group` outside the clickable portion.
87 | | `.tsmb-form [role=option] mark`
`typesense-minibar [role=option] mark` | Matching characters or words, e.g. in the excerpt.
88 | | `.tsmb-suggestion_group` | Group heading, may appear if the form is configured with `data-group=true`.
89 | | `.tsmb-suggestion_title` | Page title and optionally heading breadcrumbs to the matching paragraph.
90 | | `.tsmb-suggestion_content` | Page excerpt, typically a matching sentence.
91 | | `.tsmb-empty` | Placeholder when there are no results.
92 | | `.tsmb-icon-close` | SVG injected by JavaScript. Hidden by default. A click handler is bound that will close the results. It is recommended to make this visible only when results are visible.
**Example**: .tsmb-form--open .tsmb-icon-close { display: block !important; }
93 |
94 | Example DOM, stripped of internal details:
95 |
96 | ```html
97 |
98 |
113 |
114 | ```
115 |
116 | Example DOM, when using the HTML class:
117 |
118 | ```html
119 |
135 | ```
136 |
137 | Example DOM, when using `data-group=true`:
138 |
139 | ```html
140 |
142 |
161 |
162 | ```
163 |
164 | Example DOM, when using `data-foot=true`:
165 |
166 | ```html
167 |
169 |
180 |
181 | ```
182 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribute to typesense-minibar
2 |
3 | ## Implementation notes
4 |
5 | ### Group by url_without_anchor
6 |
7 | One of the ways in which minibar provides different (and arguably, better) default settings compared to DocSearch.js, is the removal of duplicate results from the same page.
8 |
9 | - [Demo: typesense-minibar](https://jquery.github.io/typesense-minibar/)
10 | - [Demo: Comparison to DocSearch.js](https://jquery.github.io/typesense-minibar/demo/compare--docsearch-3.html)
11 |
12 | In minibar, entering "ajax" returns these 5 results:
13 |
14 |
15 |
16 | > Low-Level Interface
17 | >
18 | > 1. jQuery.ajax()
19 | > 2. jQuery.ajaxTransport()
20 | >
21 | > Global Event Handlers
22 | >
23 | > 3. ajaxComplete event
24 | > 4. ajaxSuccess event
25 | > 5. ajaxError event
26 |
27 | In DocSearch, entering "ajax" returns these 10 results (the first 7 are visible without scrolling):
28 |
29 |
30 |
31 | > Documentation
32 | >
33 | > 1. jQuery 3.0 Upgrade Guide
34 | > 2. jQuery 3.0 Upgrade Guide
35 | > 3. jQuery 3.0 Upgrade Guide
36 | > 4. jQuery 3.0 Upgrade Guide
37 | > 5. jQuery 3.0 Upgrade Guide
38 | >
39 | > Global Event Handlers
40 | >
41 | > 6. ajaxComplete event
42 | > 7. ajaxComplete event
43 | >
44 | > Global Event Handlers (contd., after scrolling)
45 | >
46 | > 8. ajaxSuccess event
47 | > 9. ajaxSuccess event
48 | > 10. ajaxError event
49 |
50 | ### The `cache` Map
51 |
52 | This lets us instantly display any previous result in the same browser tab. We follow the [LRU strategy](https://en.wikipedia.org/wiki/Least_recently_used).
53 |
54 | For example, if you type `a`, `app`, `apple`, `apples`, and backspace to a shorter previous query, we instantly show those previous results. (No time wasted waiting for re-download of the same results. It also saves client bandwidth and server load.) Or, if you think another word might yield better results and replace it with `banana`, and return to `apple` because that had a better one, we respond instantly.
55 |
56 | We keep up to 100 past results in memory. After that, we prioritize keeping the most recently shown data, and delete older unused results. We assume that you're most likely to return to what you've seen most recently. (Maybe not within the last 10, but within 100. Even if you do return to the very first after a hundred, you're likely to pass by more recent ones on the way there. All queries have equal cost.) When we store a new result, or when we re-use an old result, we delete it and re-set it, so that it goes to the "bottom" of the map.
57 |
58 | When it is time to delete an old result, we take a key from the "top" of the map, which is either the oldest and never used, or the least recently used.
59 |
60 | If we only add new results and reuse results as-is ([FIFO strategy](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics))), that may delete very recently used data.
61 |
62 | ### Separate CSS selectors for class and web component
63 |
64 | The styles for `typesense-minibar` as web component, and `.tsmb-form` class name are kept independent (that is, the web component does not auto-add the class name, nor does it otherwise rely on styles for the class name, and vice versa).
65 |
66 | This is done for two reasons:
67 |
68 | 1. Avoid selector conflict for Style API.
69 | If we were to add `class="tsmb-form"` in the web component, it would mean `typesense-minibar form` and `.tsmb-form` both match. This makes the `typesense-minibar form` selector impsosible to override in CSS for downstream users, because our defaults for `.tsmb-form` (weight 0010) would continue to "win" the cascade, as being a stronger selector than `typesense-minibar form` (weight 0002).
70 | 2. Avoid a FOUC.
71 | The element should render identically and without reflows both before and after JavaScript loads. During local development it's easy to miss a FOUC if it fixes itself once JavaScript loads. By not auto-correcting these, the bugs are more obvious and we fix them.
72 |
73 | ## Internal API
74 |
75 | ```js
76 | tsminibar(document.querySelector('#myform'))`
77 | ```
78 |
79 | Function parameters:
80 |
81 | * `{HTMLElement} form`: The element must have `data-origin`, `data-collection`, and `data-key`, attributes; and contain a descendent of ``.
82 |
83 | ## Typesense API
84 |
85 | The `highlight_full_fields` and `include_fields` query is compatible with the query in [typesense-docsearch.js:/DocSearchModal.tsx](https://github.com/typesense/typesense-docsearch.js/blob/3.4.0/packages/docsearch-react/src/DocSearchModal.tsx).
86 |
87 | The backend response is documented at . In particular, we assume the following:
88 |
89 | * `hit.document.hierarchy.*`: This is already HTML-escaped. HTML tags are stripped from the original content, and special characters are escaped with HTML entities.
90 | * `hit.highlights[0].snippet`: This is also trusted HTML. Some words may be wrapped in a `` element, to highlight matching words.
91 |
92 | ## Development
93 |
94 | Start local server for the demo:
95 |
96 | ```
97 | python3 -m http.server 4100
98 | ```
99 |
100 | Open
101 |
102 | ## Release process
103 |
104 | 1. Bump version numbers
105 |
106 | ```sh
107 | export MINIBAR_VERSION=x.y.z
108 | ```
109 | ```sh
110 | sed -i'.bak' "1s/typesense-minibar [0-9\.]*/typesense-minibar $MINIBAR_VERSION/" typesense-minibar* && \
111 | sed -i'.bak' "s/minibar@[^/]*/minibar@$MINIBAR_VERSION/g" README.md && \
112 | sed -i'.bak' 's/"version": "[^"]*"/"version": "'$MINIBAR_VERSION'"/' package.json && \
113 | rm *.bak
114 | ```
115 |
116 | 2. Stage commit and push for review.
117 |
118 | ```sh
119 | git add -p && \
120 | git commit -m "Release $MINIBAR_VERSION" && \
121 | git push origin HEAD:release
122 | ```
123 |
124 | 3. Merge once CI has passed, or test locally in a secure environment using `npm install-test`.
125 |
126 | ```sh
127 | git push origin HEAD:main
128 | ```
129 |
130 | Clean up old branch:
131 |
132 | ```sh
133 | git push origin :release
134 | git remote prune origin
135 | ```
136 |
137 | 4. Push signed tag to Git and publish to npm.
138 |
139 | ```sh
140 | git tag -s $MINIBAR_VERSION -m "Release $MINIBAR_VERSION" && git push --tags
141 | ```
142 | ```sh
143 | npm publish
144 | ```
145 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright Timo Tijhof, https://github.com/jquery/typesense-minibar
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
');
368 | const input = form.firstChild;
369 | bar = tsminibar(form);
370 | const listbox = form.querySelector('[role=listbox]');
371 |
372 | mockFetchResponse = API_RESP_FULL_MATCH_SOMETHING;
373 | input.value = 'something';
374 | await expectRender(form, () => {
375 | simulate(input, 'input');
376 | });
377 | assert.false(listbox.hidden, 'listbox not hidden');
378 | assert.equal(listbox.querySelector('mark').outerHTML, 'something', 'snippet');
379 |
380 | // When backspacing and making the input empty, we hide the listbox.
381 | // Given that empty string isn't a valid query, this means no results
382 | // are rendered. If we later re-focus the input field, we should not leak
383 | // results from the last query (i.e. before the last backspace), as those
384 | // are now unrelated.
385 | mockFetchResponse = null;
386 | input.value = '';
387 | await expectRender(form, () => {
388 | simulate(input, 'input');
389 | });
390 | assert.true(listbox.hidden, 'listbox hidden');
391 |
392 | mockFetchResponse = null;
393 | simulate(document.body, 'click', { bubbles: true });
394 | assert.true(listbox.hidden, 'listbox remains hidden (document)');
395 |
396 | simulate(input, 'click', { bubbles: true });
397 | assert.true(listbox.hidden, 'listbox remains hidden (refocus)');
398 | // It would be fine if render() was more lazy and left innerHTML populated
399 | // when rendering a close() that sets `state.open = false`. It only matters
400 | // that state.hits is cleared and that any future render() call will not make
401 | // the element visible, unless it also replaces innerHTML then.
402 | // But.. for simplicity, right now, we do clear the HTML unconditonally,
403 | // so let's assert that, and detect potentially unintended changes in the future.
404 | assert.equal(listbox.querySelector('mark')?.textContent, null, 'stale snippet gone');
405 | });
406 |
407 | QUnit.test('listbox [no stale result leak after close button]', async assert => {
408 | const form = parseHTML('
');
409 | const input = form.firstChild;
410 | bar = tsminibar(form);
411 | const listbox = form.querySelector('[role=listbox]');
412 |
413 | mockFetchResponse = API_RESP_FULL_MATCH_SOMETHING;
414 | input.value = 'something';
415 | await expectRender(form, () => {
416 | simulate(input, 'input');
417 | });
418 | assert.false(listbox.hidden, 'listbox not hidden');
419 | assert.equal(listbox.querySelector('mark').outerHTML, 'something', 'snippet');
420 |
421 | // NOTE: close button empties programmatically without "input" event.
422 | // This means clean up of "input" event handler isn't reached.
423 | // The close button is responsible for clearing state.hits instead.
424 | mockFetchResponse = null;
425 | simulate(form.querySelector('svg.tsmb-icon-close'), 'click', { bubbles: true });
426 | assert.true(listbox.hidden, 'listbox hidden');
427 |
428 | mockFetchResponse = null;
429 | simulate(document.body, 'click', { bubbles: true });
430 | assert.true(listbox.hidden, 'listbox remains hidden (document)');
431 |
432 | simulate(input, 'click', { bubbles: true });
433 | assert.true(listbox.hidden, 'listbox remains hidden (refocus)');
434 | // It would be fine if render() was more lazy and left innerHTML populated
435 | // when rendering a close() that sets `state.open = false`. It only matters
436 | // that state.hits is cleared and that any future render() call will not make
437 | // the element visible, unless it also replaces innerHTML then.
438 | // But.. for simplicity, right now, we do clear the HTML unconditonally,
439 | // so let's assert that, and detect potentially unintended changes in the future.
440 | assert.equal(listbox.querySelector('mark')?.textContent, null, 'stale snippet gone');
441 | });
442 |
443 | QUnit.test('listbox [arrow key cursor]', async assert => {
444 | const form = parseHTML('