closes #25
This commit is contained in:
parent
1c2dfbb08d
commit
9898d9e6d7
5 changed files with 537 additions and 35 deletions
|
|
@ -89,6 +89,7 @@
|
|||
"pym.js": "^1.3.2",
|
||||
"svelte-fa": "^2.4.0",
|
||||
"svelte-intersection-observer": "^0.10.0",
|
||||
"svelte-search": "^2.0.1",
|
||||
"ua-parser-js": "^0.7.27"
|
||||
},
|
||||
"exports": {
|
||||
|
|
@ -211,4 +212,4 @@
|
|||
".": "./dist/index.js"
|
||||
},
|
||||
"svelte": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import Resizer from './Resizer/index.svelte';
|
||||
import { width } from './stores.js';
|
||||
import getUniqNames from './uniqNames.js';
|
||||
import Typeahead from './Typeahead/index.svelte';
|
||||
|
||||
export let embeds;
|
||||
export let breakpoints = [330, 510, 660, 930, 1200];
|
||||
|
|
@ -46,22 +47,33 @@
|
|||
/>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
{#each embeds as embed, i}
|
||||
<button
|
||||
on:click="{() => {
|
||||
activeEmbed = embed;
|
||||
}}"
|
||||
class:active="{activeEmbed === embed}"
|
||||
<div id="typeahead-container">
|
||||
<div class="embed-link">
|
||||
<a
|
||||
rel="external"
|
||||
target="_blank"
|
||||
href="{activeEmbed}"
|
||||
title="{activeEmbed}"
|
||||
>
|
||||
{embedTitles[i]}
|
||||
<a rel="external" target="_blank" href="{embed}" title="{embed}">
|
||||
<Fa icon="{faLink}" />
|
||||
</a>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
Live link <Fa icon="{faLink}" />
|
||||
</a>
|
||||
</div>
|
||||
<Typeahead
|
||||
label="Select an embed"
|
||||
value="{embedTitles[0]}"
|
||||
extract="{(d) => embedTitles[d.index]}"
|
||||
data="{embeds.map((embed, index) => ({ index, embed }))}"
|
||||
placeholder="{'Search'}"
|
||||
showDropdownOnFocus="{true}"
|
||||
on:select="{({ detail }) => {
|
||||
activeEmbed = detail.original.embed;
|
||||
}}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="preview-label" style="width:{$width}px;">
|
||||
<p>Preview</p>
|
||||
</div>
|
||||
<div id="frame-parent" style="width:{$width}px;"></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -90,29 +102,21 @@
|
|||
margin: 20px 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: center;
|
||||
margin: 0 auto 20px;
|
||||
max-width: 900px;
|
||||
button {
|
||||
margin: 0 4px 5px;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
color: #999;
|
||||
padding: 2px 2px;
|
||||
cursor: pointer;
|
||||
@include font-display;
|
||||
font-weight: 400;
|
||||
&.active {
|
||||
border-bottom: 2px solid #666;
|
||||
color: #666;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
div#typeahead-container {
|
||||
max-width: 660px;
|
||||
margin: 0 auto 15px;
|
||||
position: relative;
|
||||
div.embed-link {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: inline-block;
|
||||
z-index: 2;
|
||||
a {
|
||||
@include font-display;
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
text-decoration: none !important;
|
||||
&:hover {
|
||||
color: #666;
|
||||
}
|
||||
|
|
@ -120,6 +124,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
div#preview-label {
|
||||
margin: 0 auto;
|
||||
p {
|
||||
@include font-display;
|
||||
color: #aaa;
|
||||
font-size: 0.75rem;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
#frame-parent {
|
||||
border: 1px solid #ddd;
|
||||
margin: 0 auto;
|
||||
|
|
|
|||
133
src/components/Framer/Typeahead/fuzzy.js
Normal file
133
src/components/Framer/Typeahead/fuzzy.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* Fuzzy
|
||||
* https://github.com/myork/fuzzy
|
||||
*
|
||||
* Copyright (c) 2012 Matt York
|
||||
* Licensed under the MIT license.
|
||||
*/
|
||||
|
||||
const fuzzy = {};
|
||||
|
||||
// Return all elements of `array` that have a fuzzy
|
||||
// match against `pattern`.
|
||||
fuzzy.simpleFilter = function (pattern, array) {
|
||||
return array.filter(function (str) {
|
||||
return fuzzy.test(pattern, str);
|
||||
});
|
||||
};
|
||||
|
||||
// Does `pattern` fuzzy match `str`?
|
||||
fuzzy.test = function (pattern, str) {
|
||||
return fuzzy.match(pattern, str) !== null;
|
||||
};
|
||||
|
||||
// If `pattern` matches `str`, wrap each matching character
|
||||
// in `opts.pre` and `opts.post`. If no match, return null
|
||||
fuzzy.match = function (pattern, str, opts) {
|
||||
opts = opts || {};
|
||||
let patternIdx = 0;
|
||||
const result = [];
|
||||
const len = str.length;
|
||||
let totalScore = 0;
|
||||
let currScore = 0;
|
||||
// prefix
|
||||
const pre = opts.pre || '';
|
||||
// suffix
|
||||
const post = opts.post || '';
|
||||
// String to compare against. This might be a lowercase version of the
|
||||
// raw string
|
||||
const compareString = (opts.caseSensitive && str) || str.toLowerCase();
|
||||
let ch;
|
||||
|
||||
pattern = (opts.caseSensitive && pattern) || pattern.toLowerCase();
|
||||
|
||||
// For each character in the string, either add it to the result
|
||||
// or wrap in template if it's the next string in the pattern
|
||||
for (let idx = 0; idx < len; idx++) {
|
||||
ch = str[idx];
|
||||
if (compareString[idx] === pattern[patternIdx]) {
|
||||
ch = pre + ch + post;
|
||||
patternIdx += 1;
|
||||
|
||||
// consecutive characters should increase the score more than linearly
|
||||
currScore += 1 + currScore;
|
||||
} else {
|
||||
currScore = 0;
|
||||
}
|
||||
totalScore += currScore;
|
||||
result[result.length] = ch;
|
||||
}
|
||||
|
||||
// return rendered string if we have a match for every char
|
||||
if (patternIdx === pattern.length) {
|
||||
// if the string is an exact match with pattern, totalScore should be maxed
|
||||
totalScore = compareString === pattern ? Infinity : totalScore;
|
||||
return { rendered: result.join(''), score: totalScore };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// The normal entry point. Filters `arr` for matches against `pattern`.
|
||||
// It returns an array with matching values of the type:
|
||||
//
|
||||
// [{
|
||||
// string: '<b>lah' // The rendered string
|
||||
// , index: 2 // The index of the element in `arr`
|
||||
// , original: 'blah' // The original element in `arr`
|
||||
// }]
|
||||
//
|
||||
// `opts` is an optional argument bag. Details:
|
||||
//
|
||||
// opts = {
|
||||
// // string to put before a matching character
|
||||
// pre: '<b>'
|
||||
//
|
||||
// // string to put after matching character
|
||||
// , post: '</b>'
|
||||
//
|
||||
// // Optional function. Input is an entry in the given arr`,
|
||||
// // output should be the string to test `pattern` against.
|
||||
// // In this example, if `arr = [{crying: 'koala'}]` we would return
|
||||
// // 'koala'.
|
||||
// , extract: function(arg) { return arg.crying; }
|
||||
// }
|
||||
fuzzy.filter = function (pattern, arr, opts) {
|
||||
if (!arr || arr.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (typeof pattern !== 'string') {
|
||||
return arr;
|
||||
}
|
||||
opts = opts || {};
|
||||
return (
|
||||
arr
|
||||
.reduce(function (prev, element, idx, arr) {
|
||||
let str = element;
|
||||
if (opts.extract) {
|
||||
str = opts.extract(element);
|
||||
}
|
||||
const rendered = fuzzy.match(pattern, str, opts);
|
||||
if (rendered != null) {
|
||||
prev[prev.length] = {
|
||||
string: rendered.rendered,
|
||||
score: rendered.score,
|
||||
index: idx,
|
||||
original: element,
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
}, [])
|
||||
|
||||
// Sort by score. Browsers are inconsistent wrt stable/unstable
|
||||
// sorting, so force stable by using the index in the case of tie.
|
||||
// See http://ofb.net/~sethml/is-sort-stable.html
|
||||
.sort(function (a, b) {
|
||||
const compare = b.score - a.score;
|
||||
if (compare) return compare;
|
||||
return a.index - b.index;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export default fuzzy;
|
||||
349
src/components/Framer/Typeahead/index.svelte
Normal file
349
src/components/Framer/Typeahead/index.svelte
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
<script>
|
||||
/**
|
||||
* @typedef {Object} TItem
|
||||
* @property {number} index
|
||||
* @property {string} embed
|
||||
*/
|
||||
|
||||
export let id = 'typeahead-' + Math.random().toString(36);
|
||||
export let value = '';
|
||||
|
||||
/** @type {TItem[]} */
|
||||
export let data = [];
|
||||
|
||||
/** @type {(item: TItem) => any} */
|
||||
export let extract = (item) => item;
|
||||
|
||||
/** @type {(item: TItem) => boolean} */
|
||||
export let disable = (item) => false;
|
||||
|
||||
/** @type {(item: TItem) => boolean} */
|
||||
export let filter = (item) => false;
|
||||
|
||||
/** Set to `false` to prevent the first result from being selected */
|
||||
export let autoselect = true;
|
||||
|
||||
/**
|
||||
* Set to `keep` to keep the search field unchanged after select, set to `clear` to auto-clear search field
|
||||
* @type {"update" | "clear" | "keep"}
|
||||
*/
|
||||
export let inputAfterSelect = 'update';
|
||||
|
||||
/** @type {{ original: TItem; index: number; score: number; string: string; disabled?: boolean; }[]} */
|
||||
export let results = [];
|
||||
|
||||
/** Set to `true` to re-focus the input after selecting a result */
|
||||
export let focusAfterSelect = false;
|
||||
|
||||
/** Set to `true` to only show results when the input is focused */
|
||||
export let showDropdownOnFocus = false;
|
||||
|
||||
/**
|
||||
* Specify the maximum number of results to return
|
||||
* @type {number}
|
||||
*/
|
||||
export let limit = Infinity;
|
||||
|
||||
import fuzzy from './fuzzy.js';
|
||||
import Search from 'svelte-search';
|
||||
import { tick, createEventDispatcher, afterUpdate } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let comboboxRef = null;
|
||||
let searchRef = null;
|
||||
let hideDropdown = false;
|
||||
let selectedIndex = -1;
|
||||
let prevResults = '';
|
||||
let isFocused = false;
|
||||
|
||||
afterUpdate(() => {
|
||||
if (prevResults !== resultsId && autoselect) {
|
||||
selectedIndex = getNextNonDisabledIndex();
|
||||
}
|
||||
|
||||
if (prevResults !== resultsId && !$$slots['no-results']) {
|
||||
hideDropdown = results.length === 0;
|
||||
}
|
||||
|
||||
prevResults = resultsId;
|
||||
});
|
||||
|
||||
async function select() {
|
||||
const result = results[selectedIndex];
|
||||
|
||||
if (result.disabled) return;
|
||||
|
||||
const selectedValue = extract(result.original);
|
||||
const searchedValue = value;
|
||||
|
||||
if (inputAfterSelect === 'clear') value = '';
|
||||
if (inputAfterSelect === 'update') value = selectedValue;
|
||||
|
||||
dispatch('select', {
|
||||
selectedIndex,
|
||||
searched: searchedValue,
|
||||
selected: selectedValue,
|
||||
original: result.original,
|
||||
originalIndex: result.index,
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
if (focusAfterSelect) searchRef.focus();
|
||||
close();
|
||||
}
|
||||
|
||||
/** @type {() => number} */
|
||||
function getNextNonDisabledIndex() {
|
||||
let index = 0;
|
||||
let disabled = results[index]?.disabled ?? false;
|
||||
|
||||
while (disabled) {
|
||||
if (index === results.length) {
|
||||
index = 0;
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
disabled = results[index]?.disabled ?? false;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/** @type {(direction: -1 | 1) => void} */
|
||||
function change(direction) {
|
||||
let index =
|
||||
direction === 1 && selectedIndex === results.length - 1
|
||||
? 0
|
||||
: selectedIndex + direction;
|
||||
if (index < 0) index = results.length - 1;
|
||||
|
||||
let disabled = results[index].disabled;
|
||||
|
||||
while (disabled) {
|
||||
if (index === results.length) {
|
||||
index = 0;
|
||||
} else {
|
||||
index += direction;
|
||||
}
|
||||
|
||||
disabled = results[index].disabled;
|
||||
}
|
||||
|
||||
selectedIndex = index;
|
||||
}
|
||||
|
||||
const open = () => (hideDropdown = false);
|
||||
const close = () => (hideDropdown = true);
|
||||
|
||||
$: options = { pre: '<mark>', post: '</mark>', extract };
|
||||
$: results =
|
||||
value !== ''
|
||||
? fuzzy
|
||||
.filter(value, data, options)
|
||||
.filter(({ score }) => score > 0)
|
||||
.slice(0, limit)
|
||||
.filter((result) => !filter(result.original))
|
||||
.map((result) => ({ ...result, disabled: disable(result.original) }))
|
||||
: data.slice(0, 10).map((d) => ({ string: extract(d), original: d }));
|
||||
|
||||
$: resultsId = results.map((result) => extract(result.original)).join('');
|
||||
$: showResults = !hideDropdown && results.length > 0 && isFocused;
|
||||
$: if (showDropdownOnFocus) {
|
||||
showResults = showResults && isFocused;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:click="{({ target }) => {
|
||||
if (!hideDropdown && !comboboxRef?.contains(target)) {
|
||||
close();
|
||||
}
|
||||
}}"
|
||||
/>
|
||||
|
||||
<div
|
||||
data-svelte-typeahead
|
||||
bind:this="{comboboxRef}"
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-owns="{id}-listbox"
|
||||
class:dropdown="{results.length > 0}"
|
||||
aria-expanded="{showResults}"
|
||||
id="{id}-typeahead"
|
||||
>
|
||||
<Search
|
||||
id="{id}"
|
||||
removeFormAriaAttributes="{true}"
|
||||
{...$$restProps}
|
||||
bind:ref="{searchRef}"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="{id}-listbox"
|
||||
aria-labelledby="{id}-label"
|
||||
aria-activedescendant="{selectedIndex >= 0 &&
|
||||
!hideDropdown &&
|
||||
results.length > 0
|
||||
? `${id}-result-${selectedIndex}`
|
||||
: null}"
|
||||
bind:value
|
||||
on:type
|
||||
on:input
|
||||
on:change
|
||||
on:focus
|
||||
on:focus="{() => {
|
||||
open();
|
||||
if (showDropdownOnFocus) {
|
||||
showResults = true;
|
||||
isFocused = true;
|
||||
}
|
||||
}}"
|
||||
on:clear
|
||||
on:clear="{open}"
|
||||
on:blur
|
||||
on:keydown
|
||||
on:keydown="{(e) => {
|
||||
if (results.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
select();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
change(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
change(-1);
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
value = '';
|
||||
searchRef?.focus();
|
||||
close();
|
||||
break;
|
||||
}
|
||||
}}"
|
||||
/>
|
||||
<ul
|
||||
class:svelte-typeahead-list="{true}"
|
||||
role="listbox"
|
||||
aria-labelledby="{id}-label"
|
||||
id="{id}-listbox"
|
||||
>
|
||||
{#if showResults}
|
||||
{#each results as result, index}
|
||||
<li
|
||||
role="option"
|
||||
id="{id}-result-{index}"
|
||||
class:selected="{selectedIndex === index}"
|
||||
class:disabled="{result.disabled}"
|
||||
aria-selected="{selectedIndex === index}"
|
||||
on:click="{() => {
|
||||
if (result.disabled) return;
|
||||
selectedIndex = index;
|
||||
select();
|
||||
}}"
|
||||
on:mouseenter="{() => {
|
||||
if (result.disabled) return;
|
||||
selectedIndex = index;
|
||||
}}"
|
||||
>
|
||||
<slot result="{result}" index="{index}" value="{value}">
|
||||
{@html result.string}
|
||||
</slot>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if $$slots['no-results'] && !hideDropdown && value.length > 0 && results.length === 0}
|
||||
<div class:no-results="{true}">
|
||||
<slot name="no-results" value="{value}" />
|
||||
</div>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../scss/fonts/mixins';
|
||||
|
||||
[data-svelte-typeahead] {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
ul {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
[aria-expanded='true'] ul {
|
||||
z-index: 1;
|
||||
border: 1px solid #ddd;
|
||||
// box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
li,
|
||||
.no-results {
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
li {
|
||||
cursor: pointer;
|
||||
:global(mark) {
|
||||
padding: 0;
|
||||
background-color: #ffff9a;
|
||||
}
|
||||
}
|
||||
|
||||
li:not(:last-of-type) {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
background-color: #efefef;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: #efefef;
|
||||
}
|
||||
|
||||
.selected:hover {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global([data-svelte-search] label) {
|
||||
margin-bottom: 0.25rem;
|
||||
display: inline-flex;
|
||||
font-size: 0.75rem;
|
||||
color: #aaa;
|
||||
@include font-display;
|
||||
}
|
||||
|
||||
:global([data-svelte-search] input) {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: none;
|
||||
font-size: 1rem;
|
||||
border: 0;
|
||||
border-radius: 0 !important;
|
||||
border: 1px solid #ddd;
|
||||
@include font-sans;
|
||||
}
|
||||
|
||||
:global([data-svelte-search] input:focus) {
|
||||
outline: none;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -11279,6 +11279,11 @@ svelte-preprocess@^4.10.7:
|
|||
sorcery "^0.10.0"
|
||||
strip-indent "^3.0.0"
|
||||
|
||||
svelte-search@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/svelte-search/-/svelte-search-2.0.1.tgz#d8b5ef9ac152f045a5c74a1b75c9d51c0091c839"
|
||||
integrity sha512-JBoObru/BUk86EmuRtYBa99xnH1RB8jqDuYYJHH0PUzN9BINo1+1GZataC/m5368BG3kZRb3wZI5ztjoi1WWXg==
|
||||
|
||||
svelte2tsx@^0.5.13:
|
||||
version "0.5.13"
|
||||
resolved "https://registry.yarnpkg.com/svelte2tsx/-/svelte2tsx-0.5.13.tgz#5c36a6510d7121e38afbe0d8ec4610992c39ad87"
|
||||
|
|
|
|||
Loading…
Reference in a new issue