Kirby search bar without page reload

I wanted to implement a search bar on my homepage and tested the template in the cookbook Search | Kirby CMS .

I would like this to work without the page reloading and I started by setting up this:

<?php
    $query   = get('q');
    $results = page('mypage')->search($query, 'title|text')
?>

<form>
    <input class="search" type="search" name="q" value="<?= html($query) ?>" onkeyup="submitForm()">
    <input type="submit" value="Search">
</form>

<ul>
    <?php foreach ($results as $result): ?>
    <li>
        <a href="<?= $result->url() ?>">
        <?= $result->title() ?>
        </a>
    </li>
    <?php endforeach ?>
</ul>

<script>
    function submitForm() {
        if (document.querySelector(".search").value >= 3) {
            ...
        }            
    }
</script>

I am not really sure how to continue from here and/or if this is even a correct approach.

Any help on how to setup an AJAX request or similar would be greatly appreciated.

i think you will probably need a script here, something like Lunar. What you have is a PHP solution so i think will end up with a page reload, ultimately, even with the JavaScript submission.

https://lunrjs.com

Algolia is an option too GitHub - mlbrgl/kirby-algolia: Provides integration between Kirby (CMS) and Algolia (Search as a Service)

I had an autocomplete on my site, with a preview of 10 search results.
What I did was that I returned json from my search template. You could do that by creating another template: YOURSEARCHPAGE-TPL.json.php

In there you do the regular search as you would do in the search templates, but then you return json. For me it looked like this:

<?php
    $acResults = [
        'num' => $numResults,
        'results' => []
    ];

    foreach ($results as $article) {
        $acResults['results'][] = [
            'title' => $article->title()->value(),
            'url' => $article->url(),
            'image' => $article->heroImage()->thumb('boxOfThree')->url(),
            'description' => $article->intro()->kirbytext()
        ];
    }
    echo json_encode($acResults);

you can add/remove whatever fields you need. You’ll get json like this:

{
num: 2,
results: [
{
title: 'test page',
url: 'https://hello.tld/test-page',
…
},
{
title: 'test page 2',
url: 'https://hello.tld/test-page-2',
…
},
]

Then you can query the search page in your javascript with something like:

I just copied old stuff from my site, there may be better solutions by now. But it might be a good starting point

Thanks, mauricehh, this is looking like the solution I imagined. But some things are not working for me - I probably did something wrong but I couldn’t identify the issue yet.

Currently, I have my template home.php with this inside:

<?php
    $query   = get('q');
    $results = page('mypage')->search($query, 'title|text')
?>

<form>
    <input class="search" type="search" name="q" value="<?= html($query) ?>">
    <input type="submit" value="Search">
</form>

<ul>
    <?php foreach ($results as $result): ?>
    <li>
        <a href="<?= $result->url() ?>">
        <?= $result->title() ?>
        </a>
    </li>
    <?php endforeach ?>
</ul>

<script>
    let inputField = document.querySelector(".search");

    inputField.addEventListener('keyup', event => {

        const keyword = inputField.value.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&')

        if (keyword.length < 2) {
            return false
        }

        clearTimeout(timer);
        timer = setTimeout(() => {
            fetch('/home.json?q=' + keyword, {
                method: 'GET',
            })
                .then(response => response.json())
                .then(response => {

                    response.forEach((searchResult) => {
                        const name = searchResult.name;
                    })

                    console.log(name);
                })
                .catch(error => {
                    console.log(error)
                })
        }, 300);
    })
</script>

and a home.json.php containing this:

<?php
    $searchResults = [
        'num' => $numResults,
        'results' => []
    ];

    foreach ($results as $item) {
        $searchResults['results'][] = [
            'name' => $item->name(),
            'url' => $item->url(),
            'image' => $item->logo()->toFile()->url(),
        ];
    }
    echo json_encode($searchResults);

When typing something into the search field, I get an error message in the console, saying timer is not defined. Does it have to be this by any chance?

    let timer = setTimeout(() => {
        ...
    }, 300);
    clearTimeout(timer);

Furthermore, if I remove clearTimeout(timer); and type something again, I get “response.forEach is not a function”.

I’m not sure what’s wrong here and would appreciate further assistance.

Hey,

in you home.json.php use count($results) instead of $numResults. Try opening your homepage with /home.json?q=KEYWORD and check if you see the json you would expect for that query.

You’re right with the timer, that was a copy & paste failure of me.
As well as the foreach, which must be response.results.forEach(… sorry.

In general it might be a good idea to check if response (and response.results) aren’t undefined before the forEach loop. I am doing that in another part of my code, which isn’t in the code example.

I applied the changes and also had to put the variables $query and $results into my home.json.php.
Opening localhost/home.json?q=mykeyword now shows me the correct JSON data for different keywords, so this is working.

Now, for testing purposes, I want to console log some of that data.

if (keyword.length < 2) {
    return false
}
console.log("this works")

let timer = setTimeout(() => {
    fetch('/home.json?q=' + keyword, {
        method: 'GET',
    })
        .then(response => response.json())
        .then(response => {

            response.results.forEach((searchResult) => {
                const name = searchResult.name;
                const url = searchResult.url;
                const img = searchResult.image;
                console.log(name); 
            })
            
        })
        .catch(error => {
            console.log(error)
        })
}, 300);
clearTimeout(timer);

The first console.log gets executed but after this nothing happens.

Maybe try loggin response.results before the forEach loop, to see if it reaches that code. If the requets fails it should be logging the error from the catch block.

Thanks for pointing that out. response wasn’t defined.
I managed to get it all working:

inputField.addEventListener('keyup', event => {

    const keyword = inputField.value.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&')

    if (keyword.length < 2) {
        return false
    }

    let response = setTimeout(()=> {
        fetch('/home.json?q=' + keyword, {method: 'GET'})
        .then(response => response.json())
        .then(response => {
            response.results.forEach((searchResult) => {
                const name = searchResult.name;
                const url = searchResult.url;
                const img = searchResult.image;
                console.log(name); 
                // append search results
            })
        })
        .catch(error => {
            console.log(error)
        })
    }, 300);
    //clearTimeout(response);
})

Only thing is, if I add that clearTimeout function at the end, it seems to stop working and I couldn’t figure out why. Do you know why that is?
Other than that, everything is working fine now and in this process I learned a lot about handling requests in Javascript! :slight_smile:
Thanks a bunch for your help!

Great I could help :slight_smile:

Unfortunately you built a little code smell there :grimacing:
You now defined response and then hand in the setTimeout return value. Technically response is now defined, but it is the timer at this point. Then, when a result from your API comes in, response may be overwritten by that API result.

This way you can always access the response in the then block, but you’re setting wrong values. That’s why the clearTimeout() won’t work always. At some point clearTimeout may be called with the API result instead of the setTimeout value and it fails.

You should rename let response = to const timer again and also hand in timer to the clearTimeout() and then check if the real response from the api is set by doing something like

if(!response || !response.results) { return false; }

before the loop.

Oh, I see. That makes sense.
Okay, last question (hopefully, haha).
I did what you said + put this before the timer:

let response = fetch('/home.json?q=' + keyword, {method: 'GET'});

console.log(response, response.results)

if (!response || !response.results) {
    return false
}

Now, the thing is, I don’t think I declared response correctly. It logs response but response.results is undefined.

You know what, actually, my initial question is answered and the timer is just a bonus I can also implement later. Gotta get the search completed first.
I’ll change this topic to “solved”. Thanks again for helping.

Edit: Did get it to work.
This is my final code:

<script>
    let inputField = document.querySelector(".search");
    let timer = null;

    inputField.addEventListener('keyup', event => {

        const keyword = inputField.value.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&')

        if (keyword.length < 3) {
            return false
        }

        let response = fetch('/home.json?q=' + keyword, {method: 'GET'});

        clearTimeout(timer);
        timer = setTimeout(()=> {
            fetch('/home.json?q=' + keyword, {method: 'GET'})
            .then(response => response.json())
            .then(response => {
                response.results.forEach((searchResult) => {
                    const name = searchResult.name;
                    const url = searchResult.url;
                    const img = searchResult.image;
                    console.log(name); 
                })
            })
            .catch(error => {
                console.log(error)
            })
        }, 300);

    })
</script>

Hey,
I would then leave the timeout away and only use the code within the currrent timeout callback. You are now doing two parallel search requests, which both write different type of values into response. Sorry, I had to reply again, but I cannot leave you with broken code :sweat_smile: just be aware, that your solution might result in errors.

To get rid of the timer just do:

const keyword = inputField.value.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&')

        if (keyword.length < 3) {
            return false
        }

          fetch('/home.json?q=' + keyword, {method: 'GET'})
            .then(response => response.json())
            .then(response => {
               if(!response || !response.results) {
                  return false;
               }

                response.results.forEach((searchResult) => {
                    const name = searchResult.name;
                    const url = searchResult.url;
                    const img = searchResult.image;
                    console.log(name); 
                })
            })
            .catch(error => {
                console.log(error)
            })

I appreciate your helpfulness, Maurice!
Working with fetching data for the first time, I might have misunderstood the response object. But I guess with let response = fetch ... I do send a request I don’t need to.

I actually already completed the technical part of my search earlier this day. Even added a suggested searches feature like Unsplash has, where you click a keyword, which then pops up in the search bar and triggers the search request.
If that’s possible, I might also add a “what others have searched” feature, but that’s a task for some other day :smile:

So, yeah, I will check my code again and remove any concurrent search requests. Thanks for the advice!