This commit is contained in:
Jon McClure 2022-09-03 20:58:56 +01:00
parent 1c2dfbb08d
commit 9898d9e6d7
5 changed files with 537 additions and 35 deletions

View file

@ -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": {

View file

@ -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;

View 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;

View 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>

View file

@ -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"