elcobvg / svelte-autocomplete

A lightweight typeahead / autocomplete component made with Svelte.js
http://svelte-autocomplete.surge.sh/
MIT License
114 stars 18 forks source link

Is it compatible with Svelte 3? #9

Open odoo-mastercore opened 5 years ago

ghost commented 5 years ago

It's not. The syntax is very different. Is anyone working on updating this for the latest SvelteJS?

elcobvg commented 5 years ago

Not updated for svelte v3 yet. As soon as I have the time...

ghost commented 5 years ago

Happy to help if you could explain to me the logic behind it. I'm learning Svelte right now and this would be a great exercise for me, though I'm very beginner atm.

I'm trying to understand how it works in plain javascript here - https://www.w3schools.com/howto/howto_js_autocomplete.asp but it looks more complicated than it needs to be!

northkode commented 5 years ago

Svelte 3.6.7 @mikeyhan1 @odoo-mastercore

<script>

        const regExpEscape = (s) => {
            return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&")
        }

            export let name= '';
            export let value= '';
            export let placeholder = '';
            export let required= false;
            export let disabled= false;

            // autocomplete props
            export let items= [];
            export let isOpen= false;
            export let results= [];
            export let search= '';
            export let isLoading= false;
            export let arrowCounter= 0;

      let className= '';
      let isAsync= false;
      let minChar= 2;
      let maxItems= 10;
      let fromStart= true; // Default type ahead
            let list;
            let input;

            async function onChange (event) {
                // Is the data given by an outside ajax request?
                if (isAsync) {
                    isLoading = true;
                } else if (search.length >= Number(minChar)) {
                    filterResults()
                    isOpen = true;
                }
            }
            function filterResults () {
                results = items.filter(item => {
                    if (typeof item !== 'string') {
                        item = item.key || '' // Silent fail
                    }
                    return fromStart ? item.toUpperCase().startsWith(search.toUpperCase())
                                                     : item.toUpperCase().includes(search.toUpperCase())
                })
                .map(item => {
                    const text = typeof item !== 'string' ? item.key : item
                    return {
                        key: text,
                        value: item.value || item,
                        label: search.trim() === '' ? text : text.replace(RegExp(regExpEscape(search.trim()), 'i'), "<span>$&</span>")
                    }
                });

                const height = results.length > maxItems ? maxItems : results.length
                list.style.height = `${height * 2.25}rem`
            }
    function onKeyDown (event) {
      if (event.keyCode === 40 && arrowCounter < results.length) {
        // ArrowDown
        arrowCounter =  arrowCounter + 1 
      } else if (event.keyCode === 38 && arrowCounter > 0) {
        // ArrowUp
        arrowCounter =  arrowCounter - 1;
      } else if (event.keyCode === 13) {
        // Enter
        event.preventDefault()
        if (arrowCounter === -1) {
          arrowCounter = 0 // Default select first item of list
        }
        close(arrowCounter)
      } else if (event.keyCode === 27) {
        // Escape
        event.preventDefault()
        close()
      }
    }
    function close (index = -1) {
      isOpen = false; 
            arrowCounter = -1;
        input.blur();
      if (index > -1) {
        value = results[index].value;
                key = results[index].key;
      } else if (!value) {
        search = ''
      }
    }
  function onupdate ({ changed, current }) {
    if (isAsync && changed.items && current.items.length) {
       items = current.items;
       isLoading = false;
       isOpen = true;
       filterResults();
    }
    }
</script>

<style>
  * {
    box-sizing: border-box;
  }

  input {
    height: 2rem;
    font-size: 1rem;
    padding: 0.25rem 0.5rem;
  }

  .autocomplete {
    position: relative;
  }

  .hide-results {
    display: none;
  }

  .autocomplete-results {
    padding: 0;
    margin: 0;
    border: 1px solid #dbdbdb;
    height: 6rem;
    overflow: auto;
    width: 100%;

    background-color: white;
    box-shadow: 2px 2px 24px rgba(0, 0, 0, 0.1);
    position: absolute;
    z-index: 100;
  }

  .autocomplete-result {
    color: #7a7a7a;
    list-style: none;
    text-align: left;
    height: 2rem;
    padding: 0.25rem 0.5rem;
    cursor: pointer;
  }

  .autocomplete-result > :global(span) {
    background-color: none;
    color: #242424;
    font-weight: bold;
  }

  .autocomplete-result.is-active,
  .autocomplete-result:hover {
    background-color: #dbdbdb;
  }
</style>
<svelte:window on:click="{()=>close()}" />
<div on:click="{(event)=>event.stopPropagation()}" class="autocomplete">
  <input
    type="text"
    class="{className}"
    {name}
    {placeholder}
    {required}
    {disabled}
    value="{value || ''}"
    autocomplete="{name}"
    bind:value="{search}"
    on:input="{(event)=>onChange(event)}"
    on:focus
    on:blur
    on:keydown="{(event)=>onKeyDown(event)}"
    bind:this={input}
  >
  <ul class="autocomplete-results{!isOpen ? ' hide-results' : ''}" bind:this={list}>
        {#each results as result, i}
                <li on:click="{()=>close(i)}" class="autocomplete-result{ i === arrowCounter ? ' is-active' : '' }">
                {@html result.label}
                </li>
        {/each}
  </ul>
{#if isLoading}
  <slot>
    <p class="fallback">Loading data...</p>
  </slot>
{/if}
</div>

https://svelte.dev/repl/72a022c606aa4509abc6b00401538235?version=3.6.7

Gets you 99% of the way there

elcobvg commented 5 years ago

Thanks for this, @northkode ! Looks good, but why do you say 'gets you 99% there'?

northkode commented 5 years ago

Some of the padding and styles aren't perfect. I didnt have time to adjust them as svelte does slightly change some globle styles etc.

Didnt want to say it was perfect cause I only spent about 10 minutes on it! Lol

MintyMods commented 5 years ago

Perfect, Gonna steal this and tweak a little for my project.

Thanks Guys

jdevine commented 4 years ago

Wondering about the onupdate function

is that just a remnant of pre-v3 svelte? seems similar to the current store.update, but I'm not grokking it.

elcobvg commented 4 years ago

Sorry @jdevine , it's not compatible with Svelte v3 yet. Haven't had the time yet to update.

ghost commented 4 years ago

@northkode : Impressive :) Did you get async to work as well?

northkode commented 4 years ago

I did on a local copy. Not on this one above however. I could try find it and post an update.

rrosiek commented 4 years ago

I took what @northkode did and tweaked it a bit for my local implementation. I removed a lot of the flexibility, again, for brevity and the fact I'm only using it for a specific project. Could put a PR in that's more aligned with the original if people are interested.

@tomzij this is how I handled the async piece, not sure if it's usable for everyone's scenario.

<script>
  import { scale } from "svelte/transition";
  import { quintOut } from "svelte/easing";

  const regExpEscape = s => {
    return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
  };

  export let asyncFunc = null;
  export let id = "";
  export let items = [];
  export let maxItems = 10;
  export let minChar = 2;
  export let name = "";
  export let results = [];
  export let value = {};

  let arrowCounter = 0;
  let input;
  let loading = false;
  let open = false;
  let search = "";
  let timer = null;

  const clickAway = event => {
    timer = setTimeout(() => {
      open = false;
    }, 200);
  };

  const clickOn = event => clearTimeout(timer);

  const onChange = event => {
    const term = event.target.value;

    if (term.length >= Number(minChar)) {
      if (asyncFunc !== null) {
        loading = true;

        clearTimeout(timer);

        timer = setTimeout(async () => {
          items = await asyncFunc(term);
          loading = false;
          open = true;
          filterResults(term);
        }, 500);
      } else {
        open = true;
        filterResults(term);
      }
    } else {
      results = [];
    }
  };

  const filterResults = term => {
    results = items
      .filter(item => {
        if (typeof item !== "string") item = item.value || "";

        return item.toUpperCase().includes(term.toUpperCase());
      })
      .map((item, i) => {
        const text = typeof item !== "string" ? item.value : item;

        return {
          key: item.key || i,
          value: text,
          label:
            term.trim() === ""
              ? text
              : text.replace(
                  RegExp(regExpEscape(term.trim()), "i"),
                  "<span class='font-semibold'>$&</span>"
                )
        };
      })
      .slice(0, maxItems - 1);
  };

  const onKeyDown = event => {
    if (event.keyCode === 40 && arrowCounter < results.length - 1) {
      arrowCounter++;
    } else if (event.keyCode === 38 && arrowCounter > 0) {
      arrowCounter--;
    } else if (event.keyCode === 13) {
      event.preventDefault();

      if (arrowCounter === -1) arrowCounter = 0;

      close(arrowCounter);
    } else if (event.keyCode === 27) {
      event.preventDefault();
      close();
    }
  };

  const close = (index = -1) => {
    open = false;
    arrowCounter = -1;
    input.blur();

    if (index > -1) {
      value = results[index];
      search = results[index].value;
    } else {
      search = "";
    }
  };
</script>

<div class="mt-1 relative rounded-md shadow-sm">
  <input
    on:blur={clickAway}
    on:focus={clickOn}
    on:keydown={event => onKeyDown(event)}
    on:input={event => onChange(event)}
    bind:value={search}
    bind:this={input}
    class="form-input block w-full pr-10 sm:text-sm sm:leading-5"
    type="text"
    {id}
    {name} />
  {#if loading}
    <div
      class="absolute inset-y-0 right-0 pr-3 flex items-center
      pointer-events-none">
      <img class="h-4 w-4 opacity-50" src="/bars_dark.svg" alt="Loading" />
    </div>
  {/if}
  {#if open}
    <div
      class="origin-top-right absolute right-0 mt-2 w-full rounded-md shadow-lg
      z-10"
      transition:scale={{ duration: 150, delay: 0, opacity: 0.2, start: 0.0, easing: quintOut }}>
      <div class="rounded-md bg-white shadow-xs">
        <div class="py-1">
          {#if results.length === 0}
            <div
              class="block px-4 py-2 text-sm cursor-pointer text-gray-700
              hover:bg-gray-100">
              No matches
            </div>
          {:else}
            {#each results as result, i}
              <div
                on:click={() => close(i)}
                class="block px-4 py-2 text-sm cursor-pointer text-gray-700
                hover:bg-gray-100 {i === arrowCounter ? 'bg-gray-100' : ''}">
                {@html result.label}
              </div>
            {/each}
          {/if}
        </div>
      </div>
    </div>
  {/if}
</div>
svmartin commented 4 years ago

I did on a local copy. Not on this one above however. I could try find it and post an update.

@northkode Could you post an update with async? That would be great!

northkode commented 4 years ago

@svmartin This is a simple example. There are some things that would need to be fixed for your individual apps but this is one way to use it. Styles could be fixed, but that is easy for your individual needs.

https://svelte.dev/repl/7641903573c943799c63f9772b0d033f?version=3.24.0