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">
|
<script module lang="ts">
|
||||||
// @ts-ignore raw
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
import componentDocs from './stories/docs/component.md?raw';
|
|
||||||
|
|
||||||
import Framer from './Framer.svelte';
|
import Framer from './Framer.svelte';
|
||||||
|
|
||||||
import { withComponentDocs } from '$lib/docs/utils/withParams.js';
|
const { Story } = defineMeta({
|
||||||
|
|
||||||
export const meta = {
|
|
||||||
title: 'Components/Utilities/Framer',
|
title: 'Components/Utilities/Framer',
|
||||||
component: Framer,
|
component: Framer,
|
||||||
...withComponentDocs(componentDocs),
|
});
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { Template, Story } from '@storybook/addon-svelte-csf';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Template>
|
|
||||||
{#snippet children({ args })}
|
|
||||||
<Framer {...args} />
|
|
||||||
{/snippet}
|
|
||||||
</Template>
|
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
name="Default"
|
name="Demo"
|
||||||
args={{
|
args={{
|
||||||
embeds: [
|
embeds: [
|
||||||
'https://graphics.reuters.com/USA-CONGRESS/FUNDRAISING/zjvqkawjlvx/embeds/en/embed/?zzz',
|
'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 lang="ts">
|
||||||
<script>
|
// @ts-ignore Temporary
|
||||||
import Fa from 'svelte-fa/src/fa.svelte';
|
import Fa from 'svelte-fa/src/fa.svelte';
|
||||||
import { faDesktop, faLink } from '@fortawesome/free-solid-svg-icons';
|
import { faDesktop, faLink } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { onMount, afterUpdate } from 'svelte';
|
|
||||||
import pym from 'pym.js';
|
import pym from 'pym.js';
|
||||||
import urljoin from 'proper-url-join';
|
import urljoin from 'proper-url-join';
|
||||||
import Resizer from './Resizer/index.svelte';
|
import Resizer from './Resizer/index.svelte';
|
||||||
import { width } from './stores.js';
|
import { width } from './stores';
|
||||||
import getUniqNames from './uniqNames.js';
|
import getUniqNames from './uniqNames';
|
||||||
import Typeahead from './Typeahead/index.svelte';
|
import Typeahead from './Typeahead/index.svelte';
|
||||||
|
import ReutersGraphicsLogo from '../ReutersGraphicsLogo/ReutersGraphicsLogo.svelte';
|
||||||
|
|
||||||
export let embeds;
|
interface Props {
|
||||||
export let breakpoints = [330, 510, 660, 930, 1200];
|
embeds: string[];
|
||||||
export let minFrameWidth = 320;
|
breakpoints?: number[];
|
||||||
export let maxFrameWidth = 1200;
|
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];
|
if (typeof window === 'undefined') return embeds[0];
|
||||||
const lastActiveEmbed = window.localStorage.getItem('framer-active-embed');
|
const lastActiveEmbed = window.localStorage.getItem('framer-active-embed');
|
||||||
if (!lastActiveEmbed) return embeds[0];
|
if (!lastActiveEmbed) return embeds[0];
|
||||||
|
|
@ -23,12 +33,16 @@
|
||||||
return embeds[0];
|
return embeds[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
let activeEmbed = getDefaultEmbed();
|
let activeEmbed = $state(getDefaultEmbed(embeds));
|
||||||
let activeEmbedIndex = embeds.indexOf(activeEmbed);
|
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.
|
// Bit of hack for handling adding query strings dynamically to embeds.
|
||||||
// cf. also the value prop on the Typeahead component...
|
// cf. also the value prop on the Typeahead component...
|
||||||
const activeEmbed =
|
const activeEmbed =
|
||||||
|
|
@ -43,55 +57,58 @@
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
$effect(() => {
|
||||||
reframe(activeEmbed);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterUpdate(() => {
|
|
||||||
reframe(activeEmbed);
|
reframe(activeEmbed);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<img
|
<ReutersGraphicsLogo width="120px" />
|
||||||
src="https://graphics.thomsonreuters.com/style-assets/images/logos/reuters-graphics-logo/svg/graphics-logo-dark.svg"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="typeahead-container">
|
{#if embeds.length === 0}
|
||||||
<div class="embed-link">
|
<div class="no-embeds">
|
||||||
<a rel="external" target="_blank" href={activeEmbed} title={activeEmbed}>
|
<p>No embeds to show.</p>
|
||||||
Live link <Fa icon={faLink} />
|
</div>
|
||||||
</a>
|
{: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>
|
</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;">
|
<div id="preview-label" style="width:{$width}px;">
|
||||||
<p>Preview</p>
|
<p>Preview</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="frame-parent" style="width:{$width}px;"></div>
|
<div id="frame-parent" style="width:{$width}px;"></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="home-link">
|
<div id="home-link">
|
||||||
|
|
@ -100,18 +117,29 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Resizer {breakpoints} {minFrameWidth} {maxFrameWidth} />
|
{#if embeds.length > 0}
|
||||||
|
<Resizer {breakpoints} {minFrameWidth} {maxFrameWidth} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@use '../../scss/mixins' as mixins;
|
||||||
|
|
||||||
header {
|
header {
|
||||||
font-family: 'Knowledge', 'Source Sans Pro', Arial, sans-serif;
|
|
||||||
font-size: 50px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 20px 0;
|
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 {
|
div#typeahead-container {
|
||||||
max-width: 660px;
|
max-width: 660px;
|
||||||
margin: 0 auto 15px;
|
margin: 0 auto 15px;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { run } from 'svelte/legacy';
|
|
||||||
|
|
||||||
import { faDesktop, faMobileAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faDesktop, faMobileAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
// @ts-ignore Temporary
|
||||||
import Fa from 'svelte-fa/src/fa.svelte';
|
import Fa from 'svelte-fa/src/fa.svelte';
|
||||||
import { width } from './../stores.js';
|
import { width } from '../stores.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
breakpoints?: any;
|
breakpoints?: number[];
|
||||||
maxFrameWidth?: number;
|
maxFrameWidth?: number;
|
||||||
minFrameWidth?: number;
|
minFrameWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -17,36 +16,36 @@
|
||||||
minFrameWidth = 320,
|
minFrameWidth = 320,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let container = $state();
|
let container: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
const sliderWidth = 90;
|
const sliderWidth = 90;
|
||||||
let windowInnerWidth = $state(1200);
|
let windowInnerWidth = $state(1200);
|
||||||
let minWidth = $derived(minFrameWidth);
|
let minWidth = $derived(minFrameWidth);
|
||||||
let maxWidth = $derived(Math.min(windowInnerWidth - 70, maxFrameWidth));
|
let maxWidth = $derived(Math.min(windowInnerWidth - 70, maxFrameWidth));
|
||||||
let pixelRange = $derived(maxWidth - minWidth);
|
let pixelRange = $derived(maxWidth - minWidth);
|
||||||
run(() => {
|
|
||||||
|
$effect(() => {
|
||||||
if ($width > maxWidth) width.set(maxWidth);
|
if ($width > maxWidth) width.set(maxWidth);
|
||||||
});
|
});
|
||||||
let offset;
|
|
||||||
run(() => {
|
// svelte-ignore state_referenced_locally
|
||||||
offset = ($width - minWidth) / pixelRange;
|
let offset = $state(($width - minWidth) / pixelRange);
|
||||||
});
|
|
||||||
|
|
||||||
let sliding = $state(false);
|
let sliding = $state(false);
|
||||||
let isFocused = $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);
|
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;
|
if (!sliding || !container) return;
|
||||||
const { left } = container.getBoundingClientRect();
|
const { left } = container.getBoundingClientRect();
|
||||||
offset = Math.min(Math.max(0, e.pageX - left), sliderWidth) / sliderWidth;
|
offset = Math.min(Math.max(0, e.pageX - left), sliderWidth) / sliderWidth;
|
||||||
pixelLabel = roundToNearestFive(getPx());
|
pixelLabel = roundToNearestFive(getPx());
|
||||||
};
|
};
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isFocused) return;
|
if (!isFocused) return;
|
||||||
const { keyCode } = e;
|
const { keyCode } = e;
|
||||||
const pixelWidth = sliderWidth / pixelRange;
|
const pixelWidth = sliderWidth / pixelRange;
|
||||||
|
|
@ -59,7 +58,7 @@
|
||||||
}
|
}
|
||||||
width.set(getPx());
|
width.set(getPx());
|
||||||
};
|
};
|
||||||
const start = (e) => {
|
const start = (e: MouseEvent) => {
|
||||||
sliding = true;
|
sliding = true;
|
||||||
move(e);
|
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 lang="ts">
|
||||||
<script>
|
type TItem = {
|
||||||
/**
|
index: number;
|
||||||
* @typedef {Object} TItem
|
embed: string;
|
||||||
* @property {number} index
|
disabled?: boolean;
|
||||||
* @property {string} embed
|
};
|
||||||
*/
|
|
||||||
|
|
||||||
export let id = 'typeahead-' + Math.random().toString(36);
|
type SelectedItem = {
|
||||||
export let value = '';
|
selectedIndex: number;
|
||||||
|
searched: string;
|
||||||
|
selected: string;
|
||||||
|
original: TItem;
|
||||||
|
originalIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
/** @type {TItem[]} */
|
interface Props {
|
||||||
export let data = [];
|
id?: string;
|
||||||
|
value: string;
|
||||||
/** @type {(item: TItem) => any} */
|
label: string;
|
||||||
export let extract = (item) => item;
|
data: TItem[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
/** @type {(item: TItem) => boolean} */
|
extract?: (item: TItem) => any;
|
||||||
export let disable = (_item) => false;
|
disable?: (item: TItem) => boolean;
|
||||||
|
filter?: (item: TItem) => boolean;
|
||||||
/** @type {(item: TItem) => boolean} */
|
onselect: (item: SelectedItem) => void;
|
||||||
export let filter = (_item) => false;
|
autoselect?: boolean;
|
||||||
|
inputAfterSelect?: 'update' | 'clear' | 'keep';
|
||||||
/** Set to `false` to prevent the first result from being selected */
|
focusAfterSelect?: boolean;
|
||||||
export let autoselect = true;
|
showDropdownOnFocus?: boolean;
|
||||||
|
limit?: number;
|
||||||
/**
|
noResults?: Snippet;
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (prevResults !== resultsId && autoselect) {
|
||||||
selectedIndex = getNextNonDisabledIndex();
|
selectedIndex = getNextNonDisabledIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevResults !== resultsId && !$$slots['no-results']) {
|
if (prevResults !== resultsId && !noResults) {
|
||||||
hideDropdown = results.length === 0;
|
hideDropdown = results.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,7 +110,7 @@
|
||||||
async function select() {
|
async function select() {
|
||||||
const result = results[selectedIndex];
|
const result = results[selectedIndex];
|
||||||
|
|
||||||
if (result.disabled) return;
|
if (result.original.disabled) return;
|
||||||
|
|
||||||
const selectedValue = extract(result.original);
|
const selectedValue = extract(result.original);
|
||||||
const searchedValue = value;
|
const searchedValue = value;
|
||||||
|
|
@ -98,7 +118,7 @@
|
||||||
if (inputAfterSelect === 'clear') value = '';
|
if (inputAfterSelect === 'clear') value = '';
|
||||||
if (inputAfterSelect === 'update') value = selectedValue;
|
if (inputAfterSelect === 'update') value = selectedValue;
|
||||||
|
|
||||||
dispatch('select', {
|
onselect({
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
searched: searchedValue,
|
searched: searchedValue,
|
||||||
selected: selectedValue,
|
selected: selectedValue,
|
||||||
|
|
@ -108,11 +128,10 @@
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
if (focusAfterSelect) searchRef.focus();
|
if (focusAfterSelect) searchRef?.focus();
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {() => number} */
|
|
||||||
function getNextNonDisabledIndex() {
|
function getNextNonDisabledIndex() {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
let disabled = results[index]?.disabled ?? false;
|
let disabled = results[index]?.disabled ?? false;
|
||||||
|
|
@ -130,8 +149,7 @@
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {(direction: -1 | 1) => void} */
|
function change(direction: -1 | 1) {
|
||||||
function change(direction) {
|
|
||||||
let index =
|
let index =
|
||||||
direction === 1 && selectedIndex === results.length - 1 ?
|
direction === 1 && selectedIndex === results.length - 1 ?
|
||||||
0
|
0
|
||||||
|
|
@ -158,8 +176,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
on:click={({ target }) => {
|
onclick={({ target }) => {
|
||||||
if (!hideDropdown && !comboboxRef?.contains(target)) {
|
console.log('HELLO', !comboboxRef?.contains(target as Node));
|
||||||
|
if (!hideDropdown && !comboboxRef?.contains(target as Node)) {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -178,10 +197,11 @@
|
||||||
id="{id}-typeahead"
|
id="{id}-typeahead"
|
||||||
>
|
>
|
||||||
<Search
|
<Search
|
||||||
|
bind:value
|
||||||
{id}
|
{id}
|
||||||
|
{label}
|
||||||
removeFormAriaAttributes={true}
|
removeFormAriaAttributes={true}
|
||||||
{...$$restProps}
|
bind:ref={searchRef!}
|
||||||
bind:ref={searchRef}
|
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-controls="{id}-listbox"
|
aria-controls="{id}-listbox"
|
||||||
aria-labelledby="{id}-label"
|
aria-labelledby="{id}-label"
|
||||||
|
|
@ -190,23 +210,15 @@
|
||||||
) ?
|
) ?
|
||||||
`${id}-result-${selectedIndex}`
|
`${id}-result-${selectedIndex}`
|
||||||
: null}
|
: null}
|
||||||
bind:value
|
onfocus={() => {
|
||||||
on:type
|
|
||||||
on:input
|
|
||||||
on:change
|
|
||||||
on:focus
|
|
||||||
on:focus={() => {
|
|
||||||
open();
|
open();
|
||||||
if (showDropdownOnFocus) {
|
if (showDropdownOnFocus) {
|
||||||
showResults = true;
|
showResults = true;
|
||||||
isFocused = true;
|
isFocused = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:clear
|
onclear={open}
|
||||||
on:clear={open}
|
onkeydown={(e: KeyboardEvent) => {
|
||||||
on:blur
|
|
||||||
on:keydown
|
|
||||||
on:keydown={(e) => {
|
|
||||||
if (results.length === 0) return;
|
if (results.length === 0) return;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
|
|
@ -229,6 +241,7 @@
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
<ul
|
<ul
|
||||||
class:svelte-typeahead-list={true}
|
class:svelte-typeahead-list={true}
|
||||||
|
|
@ -236,7 +249,7 @@
|
||||||
aria-labelledby="{id}-label"
|
aria-labelledby="{id}-label"
|
||||||
id="{id}-listbox"
|
id="{id}-listbox"
|
||||||
>
|
>
|
||||||
{#if showResults}
|
{#if showResults && !hideDropdown}
|
||||||
{#each results as result, index}
|
{#each results as result, index}
|
||||||
<li
|
<li
|
||||||
role="option"
|
role="option"
|
||||||
|
|
@ -244,25 +257,23 @@
|
||||||
class:selected={selectedIndex === index}
|
class:selected={selectedIndex === index}
|
||||||
class:disabled={result.disabled}
|
class:disabled={result.disabled}
|
||||||
aria-selected={selectedIndex === index}
|
aria-selected={selectedIndex === index}
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if (result.disabled) return;
|
if (result.disabled) return;
|
||||||
selectedIndex = index;
|
selectedIndex = index;
|
||||||
select();
|
select();
|
||||||
}}
|
}}
|
||||||
on:keyup={(e) => {
|
onkeyup={(e) => {
|
||||||
if (e.key !== 'Enter') return;
|
if (e.key !== 'Enter') return;
|
||||||
if (result.disabled) return;
|
if (result.disabled) return;
|
||||||
selectedIndex = index;
|
selectedIndex = index;
|
||||||
select();
|
select();
|
||||||
}}
|
}}
|
||||||
on:mouseenter={() => {
|
onmouseenter={() => {
|
||||||
if (result.disabled) return;
|
if (result.disabled) return;
|
||||||
selectedIndex = index;
|
selectedIndex = index;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<slot {result} {index} {value}>
|
{@html result.string}
|
||||||
{@html result.string}
|
|
||||||
</slot>
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export default (embeds) => {
|
export default (embeds: string[]) => {
|
||||||
const nakedEmbeds = embeds
|
const nakedEmbeds = embeds
|
||||||
.map((e) => e.replace(/\?.+$/, ''))
|
.map((e) => e.replace(/\?.+$/, ''))
|
||||||
.map((e) => e.replace(/index\.html$/, ''))
|
.map((e) => e.replace(/index\.html$/, ''))
|
||||||
Loading…
Reference in a new issue