commit
6470a386e8
10 changed files with 448 additions and 329 deletions
19
src/components/Framer/Framer.mdx
Normal file
19
src/components/Framer/Framer.mdx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Meta } from '@storybook/blocks';
|
||||
|
||||
import * as FramerStories from './Framer.stories.svelte';
|
||||
|
||||
<Meta of={FramerStories} />
|
||||
|
||||
# FeaturePhoto
|
||||
|
||||
An embed tool for development in the Graphics Kit.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Framer } from '@reuters-graphics/graphics-components';
|
||||
|
||||
const embeds = ['/embeds/my-chart/index.html'];
|
||||
</script>
|
||||
|
||||
<Framer {embeds} />
|
||||
```
|
||||
|
|
@ -1,33 +1,19 @@
|
|||
<script module lang="ts">
|
||||
// @ts-ignore raw
|
||||
import componentDocs from './stories/docs/component.md?raw';
|
||||
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Framer from './Framer.svelte';
|
||||
|
||||
import { withComponentDocs } from '$lib/docs/utils/withParams.js';
|
||||
|
||||
export const meta = {
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/Utilities/Framer',
|
||||
component: Framer,
|
||||
...withComponentDocs(componentDocs),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { Template, Story } from '@storybook/addon-svelte-csf';
|
||||
</script>
|
||||
|
||||
<Template>
|
||||
{#snippet children({ args })}
|
||||
<Framer {...args} />
|
||||
{/snippet}
|
||||
</Template>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
name="Demo"
|
||||
args={{
|
||||
embeds: [
|
||||
'https://graphics.reuters.com/USA-CONGRESS/FUNDRAISING/zjvqkawjlvx/embeds/en/embed/?zzz',
|
||||
'https://www.reuters.com/graphics/UKRAINE-CRISIS/MAP/klvymdzdrvg/embeds/en/map/',
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,31 @@
|
|||
<!-- @migration-task Error while migrating Svelte code: Can't migrate code with afterUpdate. Please migrate by hand. -->
|
||||
<script>
|
||||
<script lang="ts">
|
||||
// @ts-ignore Temporary
|
||||
import Fa from 'svelte-fa/src/fa.svelte';
|
||||
import { faDesktop, faLink } from '@fortawesome/free-solid-svg-icons';
|
||||
import { onMount, afterUpdate } from 'svelte';
|
||||
import pym from 'pym.js';
|
||||
import urljoin from 'proper-url-join';
|
||||
import Resizer from './Resizer/index.svelte';
|
||||
import { width } from './stores.js';
|
||||
import getUniqNames from './uniqNames.js';
|
||||
import { width } from './stores';
|
||||
import getUniqNames from './uniqNames';
|
||||
import Typeahead from './Typeahead/index.svelte';
|
||||
import ReutersGraphicsLogo from '../ReutersGraphicsLogo/ReutersGraphicsLogo.svelte';
|
||||
|
||||
export let embeds;
|
||||
export let breakpoints = [330, 510, 660, 930, 1200];
|
||||
export let minFrameWidth = 320;
|
||||
export let maxFrameWidth = 1200;
|
||||
interface Props {
|
||||
embeds: string[];
|
||||
breakpoints?: number[];
|
||||
minFrameWidth?: number;
|
||||
maxFrameWidth?: number;
|
||||
}
|
||||
|
||||
const getDefaultEmbed = () => {
|
||||
let {
|
||||
embeds = [],
|
||||
breakpoints = [330, 510, 660, 930, 1200],
|
||||
minFrameWidth = 320,
|
||||
maxFrameWidth = 1200,
|
||||
}: Props = $props();
|
||||
|
||||
const getDefaultEmbed = (embeds: Props['embeds']) => {
|
||||
if (embeds.length === 0) return '';
|
||||
if (typeof window === 'undefined') return embeds[0];
|
||||
const lastActiveEmbed = window.localStorage.getItem('framer-active-embed');
|
||||
if (!lastActiveEmbed) return embeds[0];
|
||||
|
|
@ -23,12 +33,16 @@
|
|||
return embeds[0];
|
||||
};
|
||||
|
||||
let activeEmbed = getDefaultEmbed();
|
||||
let activeEmbedIndex = embeds.indexOf(activeEmbed);
|
||||
let activeEmbed = $state(getDefaultEmbed(embeds));
|
||||
let activeEmbedIndex = $derived(embeds.indexOf(activeEmbed));
|
||||
|
||||
$: embedTitles = getUniqNames(embeds);
|
||||
let embedTitles = $derived.by(() => {
|
||||
if (embeds.length === 0) return '';
|
||||
return getUniqNames(embeds);
|
||||
});
|
||||
|
||||
const reframe = (embed) => {
|
||||
const reframe = (embed: string) => {
|
||||
if (!embed) return;
|
||||
// Bit of hack for handling adding query strings dynamically to embeds.
|
||||
// cf. also the value prop on the Typeahead component...
|
||||
const activeEmbed =
|
||||
|
|
@ -43,55 +57,58 @@
|
|||
);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
reframe(activeEmbed);
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
$effect(() => {
|
||||
reframe(activeEmbed);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<img
|
||||
src="https://graphics.thomsonreuters.com/style-assets/images/logos/reuters-graphics-logo/svg/graphics-logo-dark.svg"
|
||||
alt=""
|
||||
/>
|
||||
<ReutersGraphicsLogo width="120px" />
|
||||
</header>
|
||||
|
||||
<div id="typeahead-container">
|
||||
<div class="embed-link">
|
||||
<a rel="external" target="_blank" href={activeEmbed} title={activeEmbed}>
|
||||
Live link <Fa icon={faLink} />
|
||||
</a>
|
||||
{#if embeds.length === 0}
|
||||
<div class="no-embeds">
|
||||
<p>No embeds to show.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div id="typeahead-container">
|
||||
<div class="embed-link">
|
||||
<a
|
||||
rel="external"
|
||||
target="_blank"
|
||||
href={activeEmbed}
|
||||
title={activeEmbed}
|
||||
>
|
||||
Live link <Fa icon={faLink} />
|
||||
</a>
|
||||
</div>
|
||||
<Typeahead
|
||||
label="Select an embed"
|
||||
value={embedTitles[embeds.indexOf(activeEmbed)] ||
|
||||
embedTitles[activeEmbedIndex] ||
|
||||
embedTitles[0]}
|
||||
extract={(d) => embedTitles[d.index]}
|
||||
data={embeds.map((embed, index) => ({ index, embed }))}
|
||||
showDropdownOnFocus={true}
|
||||
onselect={(detail) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(
|
||||
'framer-active-embed',
|
||||
detail.original.embed
|
||||
);
|
||||
}
|
||||
activeEmbed = detail.original.embed;
|
||||
// activeEmbedIndex = detail.original.index;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Typeahead
|
||||
label="Select an embed"
|
||||
value={embedTitles[embeds.indexOf(activeEmbed)] ||
|
||||
embedTitles[activeEmbedIndex] ||
|
||||
embedTitles[0]}
|
||||
extract={(d) => embedTitles[d.index]}
|
||||
data={embeds.map((embed, index) => ({ index, embed }))}
|
||||
placeholder={'Search'}
|
||||
showDropdownOnFocus={true}
|
||||
on:select={({ detail }) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(
|
||||
'framer-active-embed',
|
||||
detail.original.embed
|
||||
);
|
||||
}
|
||||
activeEmbed = detail.original.embed;
|
||||
activeEmbedIndex = detail.original.index;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="preview-label" style="width:{$width}px;">
|
||||
<p>Preview</p>
|
||||
</div>
|
||||
<div id="frame-parent" style="width:{$width}px;"></div>
|
||||
<div id="preview-label" style="width:{$width}px;">
|
||||
<p>Preview</p>
|
||||
</div>
|
||||
<div id="frame-parent" style="width:{$width}px;"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div id="home-link">
|
||||
|
|
@ -100,18 +117,29 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<Resizer {breakpoints} {minFrameWidth} {maxFrameWidth} />
|
||||
{#if embeds.length > 0}
|
||||
<Resizer {breakpoints} {minFrameWidth} {maxFrameWidth} />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../scss/mixins' as mixins;
|
||||
|
||||
header {
|
||||
font-family: 'Knowledge', 'Source Sans Pro', Arial, sans-serif;
|
||||
font-size: 50px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.no-embeds {
|
||||
background-color: #efefef;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
p {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
@include mixins.font-note;
|
||||
}
|
||||
}
|
||||
|
||||
div#typeahead-container {
|
||||
max-width: 660px;
|
||||
margin: 0 auto 15px;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { faDesktop, faMobileAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
// @ts-ignore Temporary
|
||||
import Fa from 'svelte-fa/src/fa.svelte';
|
||||
import { width } from './../stores.js';
|
||||
import { width } from '../stores.js';
|
||||
|
||||
interface Props {
|
||||
breakpoints?: any;
|
||||
breakpoints?: number[];
|
||||
maxFrameWidth?: number;
|
||||
minFrameWidth?: number;
|
||||
}
|
||||
|
|
@ -17,36 +16,36 @@
|
|||
minFrameWidth = 320,
|
||||
}: Props = $props();
|
||||
|
||||
let container = $state();
|
||||
let container: HTMLElement | undefined = $state();
|
||||
|
||||
const sliderWidth = 90;
|
||||
let windowInnerWidth = $state(1200);
|
||||
let minWidth = $derived(minFrameWidth);
|
||||
let maxWidth = $derived(Math.min(windowInnerWidth - 70, maxFrameWidth));
|
||||
let pixelRange = $derived(maxWidth - minWidth);
|
||||
run(() => {
|
||||
|
||||
$effect(() => {
|
||||
if ($width > maxWidth) width.set(maxWidth);
|
||||
});
|
||||
let offset;
|
||||
run(() => {
|
||||
offset = ($width - minWidth) / pixelRange;
|
||||
});
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let offset = $state(($width - minWidth) / pixelRange);
|
||||
|
||||
let sliding = $state(false);
|
||||
let isFocused = $state(false);
|
||||
|
||||
const roundToNearestFive = (d) => Math.ceil(d / 5) * 5;
|
||||
const roundToNearestFive = (d: number) => Math.ceil(d / 5) * 5;
|
||||
const getPx = () => Math.round(pixelRange * offset + minWidth);
|
||||
|
||||
let pixelLabel = $state(null);
|
||||
let pixelLabel: null | number = $state(null);
|
||||
|
||||
const move = (e) => {
|
||||
const move = (e: MouseEvent) => {
|
||||
if (!sliding || !container) return;
|
||||
const { left } = container.getBoundingClientRect();
|
||||
offset = Math.min(Math.max(0, e.pageX - left), sliderWidth) / sliderWidth;
|
||||
pixelLabel = roundToNearestFive(getPx());
|
||||
};
|
||||
const handleKeyDown = (e) => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isFocused) return;
|
||||
const { keyCode } = e;
|
||||
const pixelWidth = sliderWidth / pixelRange;
|
||||
|
|
@ -59,7 +58,7 @@
|
|||
}
|
||||
width.set(getPx());
|
||||
};
|
||||
const start = (e) => {
|
||||
const start = (e: MouseEvent) => {
|
||||
sliding = true;
|
||||
move(e);
|
||||
};
|
||||
|
|
|
|||
101
src/components/Framer/Typeahead/Search.svelte
Normal file
101
src/components/Framer/Typeahead/Search.svelte
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
autofocus?: boolean;
|
||||
debounce?: number;
|
||||
label?: string | Snippet;
|
||||
hideLabel?: boolean;
|
||||
id?: string;
|
||||
ref: HTMLElement;
|
||||
removeFormAriaAttributes?: boolean;
|
||||
ontype?: (value: string) => void;
|
||||
onclear?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
autofocus = false,
|
||||
debounce = 0,
|
||||
label = 'Label',
|
||||
hideLabel = false,
|
||||
id = 'search' + Math.random().toString(36),
|
||||
ref = $bindable(),
|
||||
removeFormAriaAttributes = false,
|
||||
ontype = (_value: string) => {},
|
||||
onclear = () => {},
|
||||
...restProps
|
||||
}: Props & HTMLInputAttributes = $props();
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let prevValue = value;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
let calling = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function debounceFn(fn: () => any) {
|
||||
if (calling) return;
|
||||
calling = true;
|
||||
timeout = setTimeout(() => {
|
||||
fn();
|
||||
calling = false;
|
||||
}, debounce);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (autofocus) window.requestAnimationFrame(() => ref?.focus());
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (value.length > 0 && value !== prevValue) {
|
||||
if (debounce > 0) {
|
||||
debounceFn(() => ontype(value));
|
||||
} else {
|
||||
ontype(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (value.length === 0 && prevValue.length > 0) onclear();
|
||||
|
||||
prevValue = value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<form
|
||||
data-svelte-search
|
||||
role={removeFormAriaAttributes ? null : 'search'}
|
||||
aria-labelledby={removeFormAriaAttributes ? null : id}
|
||||
action=""
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<label
|
||||
id="{id}-label"
|
||||
for={id}
|
||||
style={hideLabel ?
|
||||
'position: absolute;height: 1px;width: 1px;overflow: hidden;clip: rect(1px 1px 1px 1px);clip: rect(1px, 1px, 1px, 1px);white-space: nowrap;'
|
||||
: undefined}
|
||||
>
|
||||
{#if typeof label === 'string'}
|
||||
{label}
|
||||
{:else}
|
||||
{@render label()}
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
bind:this={ref}
|
||||
bind:value
|
||||
{id}
|
||||
name="search"
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
{...restProps}
|
||||
/>
|
||||
</form>
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
/*
|
||||
* 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;
|
||||
108
src/components/Framer/Typeahead/fuzzy.ts
Normal file
108
src/components/Framer/Typeahead/fuzzy.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
interface MatchOptions {
|
||||
pre?: string;
|
||||
post?: string;
|
||||
caseSensitive?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
extract?: (arg: any) => string;
|
||||
}
|
||||
|
||||
interface MatchResult {
|
||||
rendered: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface FilterResult<T> {
|
||||
string: string;
|
||||
score: number;
|
||||
index: number;
|
||||
original: T;
|
||||
}
|
||||
|
||||
const fuzzy = {
|
||||
simpleFilter(pattern: string, array: string[]): string[] {
|
||||
return array.filter((str) => this.test(pattern, str));
|
||||
},
|
||||
|
||||
test(pattern: string, str: string): boolean {
|
||||
return this.match(pattern, str) !== null;
|
||||
},
|
||||
|
||||
match(
|
||||
pattern: string,
|
||||
str: string,
|
||||
opts: MatchOptions = {}
|
||||
): MatchResult | null {
|
||||
let patternIdx = 0;
|
||||
const result: string[] = [];
|
||||
const len = str.length;
|
||||
let totalScore = 0;
|
||||
let currScore = 0;
|
||||
const pre = opts.pre || '';
|
||||
const post = opts.post || '';
|
||||
const compareString = opts.caseSensitive ? str : str.toLowerCase();
|
||||
pattern = opts.caseSensitive ? pattern : pattern.toLowerCase();
|
||||
|
||||
for (let idx = 0; idx < len; idx++) {
|
||||
let ch = str[idx];
|
||||
if (compareString[idx] === pattern[patternIdx]) {
|
||||
ch = pre + ch + post;
|
||||
patternIdx += 1;
|
||||
currScore += 1 + currScore;
|
||||
} else {
|
||||
currScore = 0;
|
||||
}
|
||||
totalScore += currScore;
|
||||
result[result.length] = ch;
|
||||
}
|
||||
|
||||
if (patternIdx === pattern.length) {
|
||||
totalScore = compareString === pattern ? Infinity : totalScore;
|
||||
return { rendered: result.join(''), score: totalScore };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
filter<T>(
|
||||
pattern: string,
|
||||
arr: T[],
|
||||
opts: MatchOptions = {}
|
||||
): FilterResult<T>[] {
|
||||
if (!arr || arr.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (typeof pattern !== 'string') {
|
||||
return arr.map((element, index) => ({
|
||||
string: element as unknown as string,
|
||||
score: 0,
|
||||
index,
|
||||
original: element,
|
||||
}));
|
||||
}
|
||||
|
||||
return arr
|
||||
.reduce<FilterResult<T>[]>((prev, element, idx) => {
|
||||
let str = element as unknown as string;
|
||||
if (opts.extract) {
|
||||
str = opts.extract(element);
|
||||
}
|
||||
const rendered = this.match(pattern, str, opts);
|
||||
if (rendered != null) {
|
||||
prev.push({
|
||||
string: rendered.rendered,
|
||||
score: rendered.score,
|
||||
index: idx,
|
||||
original: element,
|
||||
});
|
||||
}
|
||||
return prev;
|
||||
}, [])
|
||||
.sort((a, b) => {
|
||||
const compare = b.score - a.score;
|
||||
if (compare) return compare;
|
||||
return a.index - b.index;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default fuzzy;
|
||||
|
|
@ -1,86 +1,106 @@
|
|||
<!-- @migration-task Error while migrating Svelte code: Can't migrate code with afterUpdate. Please migrate by hand. -->
|
||||
<script>
|
||||
/**
|
||||
* @typedef {Object} TItem
|
||||
* @property {number} index
|
||||
* @property {string} embed
|
||||
*/
|
||||
<script lang="ts">
|
||||
type TItem = {
|
||||
index: number;
|
||||
embed: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export let id = 'typeahead-' + Math.random().toString(36);
|
||||
export let value = '';
|
||||
type SelectedItem = {
|
||||
selectedIndex: number;
|
||||
searched: string;
|
||||
selected: string;
|
||||
original: TItem;
|
||||
originalIndex: number;
|
||||
};
|
||||
|
||||
/** @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;
|
||||
|
||||
$: 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.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;
|
||||
interface Props {
|
||||
id?: string;
|
||||
value: string;
|
||||
label: string;
|
||||
data: TItem[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
extract?: (item: TItem) => any;
|
||||
disable?: (item: TItem) => boolean;
|
||||
filter?: (item: TItem) => boolean;
|
||||
onselect: (item: SelectedItem) => void;
|
||||
autoselect?: boolean;
|
||||
inputAfterSelect?: 'update' | 'clear' | 'keep';
|
||||
focusAfterSelect?: boolean;
|
||||
showDropdownOnFocus?: boolean;
|
||||
limit?: number;
|
||||
noResults?: Snippet;
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
let {
|
||||
id = 'typeahead-' + Math.random().toString(36),
|
||||
value = '',
|
||||
label = '',
|
||||
data = [],
|
||||
extract = (item) => item,
|
||||
disable = (_item) => false,
|
||||
filter = (_item) => false,
|
||||
autoselect = true,
|
||||
// Set to `keep` to keep the search field unchanged after select, set to `clear` to auto-clear search field
|
||||
inputAfterSelect = 'update',
|
||||
/** Set to `true` to re-focus the input after selecting a result */
|
||||
focusAfterSelect = false,
|
||||
/** Set to `true` to only show results when the input is focused */
|
||||
showDropdownOnFocus = false,
|
||||
/** Specify the maximum number of results to return */
|
||||
limit = Infinity,
|
||||
noResults,
|
||||
onselect,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
import fuzzy from './fuzzy';
|
||||
import Search from './Search.svelte';
|
||||
import { tick, type Snippet } from 'svelte';
|
||||
|
||||
let comboboxRef: HTMLElement | null = $state(null);
|
||||
let searchRef: HTMLElement | null = $state(null);
|
||||
let hideDropdown = $state(true);
|
||||
let selectedIndex = $state(-1);
|
||||
let prevResults = $state('');
|
||||
let isFocused = $state(false);
|
||||
|
||||
let options = $derived({ pre: '<mark>', post: '</mark>', extract });
|
||||
let results = $derived.by(() => {
|
||||
return 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.map((d, index) => ({
|
||||
index,
|
||||
string: extract(d),
|
||||
original: d,
|
||||
disabled: disable(d),
|
||||
}));
|
||||
});
|
||||
|
||||
let resultsId = $derived(
|
||||
results.map((result) => extract(result.original)).join('')
|
||||
);
|
||||
|
||||
let showResults: boolean = $state(
|
||||
// svelte-ignore state_referenced_locally
|
||||
!hideDropdown && results.length > 0 && isFocused
|
||||
);
|
||||
$effect(() => {
|
||||
if (showDropdownOnFocus) {
|
||||
showResults = showResults && isFocused;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (prevResults !== resultsId && autoselect) {
|
||||
selectedIndex = getNextNonDisabledIndex();
|
||||
}
|
||||
|
||||
if (prevResults !== resultsId && !$$slots['no-results']) {
|
||||
if (prevResults !== resultsId && !noResults) {
|
||||
hideDropdown = results.length === 0;
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +110,7 @@
|
|||
async function select() {
|
||||
const result = results[selectedIndex];
|
||||
|
||||
if (result.disabled) return;
|
||||
if (result.original.disabled) return;
|
||||
|
||||
const selectedValue = extract(result.original);
|
||||
const searchedValue = value;
|
||||
|
|
@ -98,7 +118,7 @@
|
|||
if (inputAfterSelect === 'clear') value = '';
|
||||
if (inputAfterSelect === 'update') value = selectedValue;
|
||||
|
||||
dispatch('select', {
|
||||
onselect({
|
||||
selectedIndex,
|
||||
searched: searchedValue,
|
||||
selected: selectedValue,
|
||||
|
|
@ -108,11 +128,10 @@
|
|||
|
||||
await tick();
|
||||
|
||||
if (focusAfterSelect) searchRef.focus();
|
||||
if (focusAfterSelect) searchRef?.focus();
|
||||
close();
|
||||
}
|
||||
|
||||
/** @type {() => number} */
|
||||
function getNextNonDisabledIndex() {
|
||||
let index = 0;
|
||||
let disabled = results[index]?.disabled ?? false;
|
||||
|
|
@ -130,8 +149,7 @@
|
|||
return index;
|
||||
}
|
||||
|
||||
/** @type {(direction: -1 | 1) => void} */
|
||||
function change(direction) {
|
||||
function change(direction: -1 | 1) {
|
||||
let index =
|
||||
direction === 1 && selectedIndex === results.length - 1 ?
|
||||
0
|
||||
|
|
@ -158,8 +176,9 @@
|
|||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:click={({ target }) => {
|
||||
if (!hideDropdown && !comboboxRef?.contains(target)) {
|
||||
onclick={({ target }) => {
|
||||
console.log('HELLO', !comboboxRef?.contains(target as Node));
|
||||
if (!hideDropdown && !comboboxRef?.contains(target as Node)) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
|
|
@ -178,10 +197,11 @@
|
|||
id="{id}-typeahead"
|
||||
>
|
||||
<Search
|
||||
bind:value
|
||||
{id}
|
||||
{label}
|
||||
removeFormAriaAttributes={true}
|
||||
{...$$restProps}
|
||||
bind:ref={searchRef}
|
||||
bind:ref={searchRef!}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="{id}-listbox"
|
||||
aria-labelledby="{id}-label"
|
||||
|
|
@ -190,23 +210,15 @@
|
|||
) ?
|
||||
`${id}-result-${selectedIndex}`
|
||||
: null}
|
||||
bind:value
|
||||
on:type
|
||||
on:input
|
||||
on:change
|
||||
on:focus
|
||||
on:focus={() => {
|
||||
onfocus={() => {
|
||||
open();
|
||||
if (showDropdownOnFocus) {
|
||||
showResults = true;
|
||||
isFocused = true;
|
||||
}
|
||||
}}
|
||||
on:clear
|
||||
on:clear={open}
|
||||
on:blur
|
||||
on:keydown
|
||||
on:keydown={(e) => {
|
||||
onclear={open}
|
||||
onkeydown={(e: KeyboardEvent) => {
|
||||
if (results.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
|
|
@ -229,6 +241,7 @@
|
|||
break;
|
||||
}
|
||||
}}
|
||||
{...restProps}
|
||||
/>
|
||||
<ul
|
||||
class:svelte-typeahead-list={true}
|
||||
|
|
@ -236,7 +249,7 @@
|
|||
aria-labelledby="{id}-label"
|
||||
id="{id}-listbox"
|
||||
>
|
||||
{#if showResults}
|
||||
{#if showResults && !hideDropdown}
|
||||
{#each results as result, index}
|
||||
<li
|
||||
role="option"
|
||||
|
|
@ -244,25 +257,23 @@
|
|||
class:selected={selectedIndex === index}
|
||||
class:disabled={result.disabled}
|
||||
aria-selected={selectedIndex === index}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
if (result.disabled) return;
|
||||
selectedIndex = index;
|
||||
select();
|
||||
}}
|
||||
on:keyup={(e) => {
|
||||
onkeyup={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (result.disabled) return;
|
||||
selectedIndex = index;
|
||||
select();
|
||||
}}
|
||||
on:mouseenter={() => {
|
||||
onmouseenter={() => {
|
||||
if (result.disabled) return;
|
||||
selectedIndex = index;
|
||||
}}
|
||||
>
|
||||
<slot {result} {index} {value}>
|
||||
{@html result.string}
|
||||
</slot>
|
||||
{@html result.string}
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export default (embeds) => {
|
||||
export default (embeds: string[]) => {
|
||||
const nakedEmbeds = embeds
|
||||
.map((e) => e.replace(/\?.+$/, ''))
|
||||
.map((e) => e.replace(/index\.html$/, ''))
|
||||
Loading…
Reference in a new issue