upend/webui/src/lib/components/utils/Selector.svelte

598 lines
13 KiB
Svelte

<script lang="ts" context="module">
import type { IValue } from '@upnd/upend/types';
import type { UpEntry } from '@upnd/upend';
import UpEntryComponent from '../display/UpEntry.svelte';
export type SELECTOR_TYPE =
| 'Address'
| 'LabelledAddress'
| 'NewAddress'
| 'Attribute'
| 'NewAttribute'
| 'String'
| 'Number'
| 'Null';
export type SelectorValue = {
t: SELECTOR_TYPE;
} & (
| {
t: 'Address';
c: Address;
entry?: UpEntry;
labels?: string[];
}
| {
t: 'Attribute';
name: string;
labels?: string[];
}
| {
t: 'String';
c: string;
}
| {
t: 'Number';
c: number;
}
| {
t: 'Null';
c: null;
}
);
export type SelectorOption =
| SelectorValue
| { t: 'NewAddress'; c: string }
| { t: 'NewAttribute'; name: string; label: string };
export async function selectorValueAsValue(value: SelectorValue): Promise<IValue> {
switch (value.t) {
case 'Address':
return {
t: 'Address',
c: value.c
};
case 'Attribute':
return {
t: 'Address',
c: await api.componentsToAddress({ t: 'Attribute', c: value.name })
};
case 'String':
return {
t: 'String',
c: value.c
};
case 'Number':
return {
t: 'Number',
c: value.c
};
case 'Null':
return {
t: 'Null',
c: null
};
}
}
</script>
<script lang="ts">
import { debounce } from 'lodash';
import { createEventDispatcher } from 'svelte';
import type { UpListing } from '@upnd/upend';
import type { Address } from '@upnd/upend/types';
import { baseSearchOnce, createLabelled } from '$lib/util/search';
import UpObject from '../display/UpObject.svelte';
import IconButton from './IconButton.svelte';
import Input from './Input.svelte';
import { matchSorter } from 'match-sorter';
import api from '$lib/api';
import { ATTR_LABEL } from '@upnd/upend/constants';
import { i18n } from '$lib/i18n';
import debug from 'debug';
import Spinner from './Spinner.svelte';
const dispatch = createEventDispatcher();
const dbg = debug('kestrel:Selector');
let selectorEl: HTMLElement;
export let MAX_OPTIONS = 25;
export let types: SELECTOR_TYPE[] = ['Address', 'NewAddress', 'Attribute', 'String', 'Number'];
export let attributeOptions: string[] | undefined = undefined;
export let emptyOptions: SelectorOption[] | undefined = undefined;
export let placeholder = '';
export let disabled = false;
export let keepFocusOnSet = false;
export let initial: SelectorValue | undefined = undefined;
let inputValue = '';
let updating = false;
$: setInitial(initial);
function setInitial(initial: SelectorValue | undefined) {
if (initial) {
switch (initial.t) {
case 'Address':
case 'String':
inputValue = initial.c;
break;
case 'Attribute':
inputValue = initial.name;
break;
case 'Number':
inputValue = String(initial.c);
break;
}
if (
initial.t === 'Address' ||
initial.t === 'Attribute' ||
initial.t === 'String' ||
initial.t === 'Number'
) {
current = initial;
}
}
}
let current: (SelectorOption & { t: 'Address' | 'Attribute' | 'String' | 'Number' }) | undefined =
undefined;
export function reset() {
inputValue = '';
current = undefined;
dispatch('input', current);
}
$: if (!inputValue) reset();
let options: SelectorOption[] = [];
let searchResult: UpListing | undefined = undefined;
let optionsAbortController: AbortController | undefined = undefined;
const updateOptions = debounce(async (query: string, doSearch: boolean) => {
updating = true;
let result: SelectorOption[] = [];
optionsAbortController?.abort();
const abortController = new AbortController();
optionsAbortController = abortController;
if (query.length === 0 && emptyOptions !== undefined) {
options = emptyOptions;
updating = false;
return;
}
try {
if (types.includes('Number')) {
const number = parseFloat(query);
if (!Number.isNaN(number)) {
result.push({
t: 'Number',
c: number
});
}
}
if (types.includes('String') && query.length) {
result.push({
t: 'String',
c: query
});
}
options = result;
if (types.includes('Address') || types.includes('LabelledAddress')) {
if (doSearch) {
if (emptyOptions === undefined || query.length > 0) {
searchResult = await baseSearchOnce(query, { abortController });
} else {
searchResult = undefined;
}
}
let exactHits = Object.entries(addressToLabels)
.filter(([_, labels]) => labels.map((l) => l.toLowerCase()).includes(query.toLowerCase()))
.map(([addr, _]) => addr);
if (exactHits.length) {
exactHits.forEach((addr) =>
result.push({
t: 'Address',
c: addr,
labels: addressToLabels[addr],
entry: undefined
})
);
} else if (query.length && types.includes('NewAddress')) {
result.push({
t: 'NewAddress',
c: query
});
}
let validOptions = (searchResult?.entries || []).filter(
(e) => !exactHits.includes(e.entity)
);
// only includes LabelledAddress
if (!types.includes('Address')) {
validOptions = validOptions.filter((e) => e.attribute == ATTR_LABEL);
}
const sortedOptions = matchSorter(validOptions, inputValue, {
keys: ['value.c', (i) => addressToLabels[i.entity]?.join(' ')]
});
for (const entry of sortedOptions) {
const common = {
t: 'Address' as const,
c: entry.entity
};
if (entry.attribute == ATTR_LABEL && entry.value.c) {
result.push({
...common,
labels: [entry.value.c.toString()]
});
} else {
result.push({ ...common, entry });
}
}
}
if (types.includes('Attribute')) {
let selectorOptions: SelectorOption[] = [];
const allAttributes = await api.fetchAllAttributes({ abortController });
const attributes = attributeOptions
? allAttributes.filter((attr) => attributeOptions!.includes(attr.name))
: allAttributes;
if (emptyOptions === undefined || query.length > 0) {
selectorOptions.push(
...attributes
.filter(
(attr) =>
attr.name.toLowerCase().includes(query.toLowerCase()) ||
attr.labels.some((label) => label.toLowerCase().includes(query.toLowerCase()))
)
.map(
(attribute) =>
({
t: 'Attribute',
...attribute
}) as SelectorOption
)
);
}
selectorOptions = matchSorter(selectorOptions, inputValue, {
keys: [(i) => (i.t === 'Attribute' && i.labels) || [], 'name']
});
const attributeToCreate = query.toUpperCase().replaceAll(/[^A-Z0-9]/g, '_');
if (
!attributeOptions &&
query &&
!allAttributes.map((attr) => attr.name).includes(attributeToCreate) &&
types.includes('NewAttribute')
) {
selectorOptions.push({
t: 'NewAttribute',
name: attributeToCreate,
label: query
});
}
result.push(...selectorOptions);
}
} catch (e: unknown) {
if (e && (e as Error).name !== 'AbortError') {
throw e;
}
}
if (abortController.signal.aborted) {
dbg('%o Aborted search', selectorEl);
return;
}
options = result;
updating = false;
}, 200);
$: dbg('%o Options: %O', selectorEl, options);
$: {
if (inputFocused) {
updateOptions(inputValue, true);
addressToLabels = {};
}
}
let addressToLabels: { [key: string]: string[] } = {};
function onAddressResolved(address: string, ev: CustomEvent<string[]>) {
addressToLabels[address] = ev.detail;
updateOptions(inputValue, false);
}
async function set(option: SelectorOption) {
dbg('%o Setting option %O', selectorEl, option);
switch (option.t) {
case 'Address':
inputValue = option.c;
current = option;
break;
case 'NewAddress':
{
const addr = await createLabelled(option.c);
inputValue = addr;
current = {
t: 'Address',
c: addr,
labels: [option.c]
};
}
break;
case 'Attribute':
inputValue = option.name;
current = option;
break;
case 'NewAttribute':
inputValue = option.name;
{
const address = await api.componentsToAddress({
t: 'Attribute',
c: option.name
});
await api.putEntityAttribute(address, ATTR_LABEL, {
t: 'String',
c: option.label
});
current = {
t: 'Attribute',
name: option.name,
labels: [option.label]
};
}
break;
case 'String':
inputValue = option.c;
current = option;
break;
case 'Number':
inputValue = String(option.c);
current = option;
break;
}
dbg('%o Result set value: %O', selectorEl, current);
dispatch('input', current);
options = [];
optionFocusIndex = -1;
hover = false;
if (keepFocusOnSet) {
focus();
}
}
let listEl: HTMLUListElement;
let optionFocusIndex = -1;
function handleArrowKeys(ev: KeyboardEvent) {
if (!options.length) {
return;
}
const optionEls = Array.from(listEl.children) as HTMLLIElement[];
let targetIndex = optionEls.findIndex((el) => document.activeElement === el);
switch (ev.key) {
case 'ArrowDown':
targetIndex += 1;
// pressed down on last
if (targetIndex >= optionEls.length) {
targetIndex = 0;
}
break;
case 'ArrowUp':
targetIndex -= 1;
// pressed up on input
if (targetIndex == -2) {
targetIndex = optionEls.length - 1;
}
// pressed up on first
if (targetIndex == -1) {
focus();
return;
}
break;
default:
return; // early return, stop processing
}
if (optionEls[targetIndex]) {
optionEls[targetIndex].focus();
}
}
let input: Input;
export function focus() {
// dbg("%o Focusing input", selectorEl);
input?.focus();
}
let inputFocused = false;
let hover = false; // otherwise clicking makes options disappear faster than it can emit a set
$: visible =
(inputFocused || hover || optionFocusIndex > -1) && Boolean(options.length || updating);
$: dispatch('focus', inputFocused || hover || optionFocusIndex > -1);
$: dbg('%o focus = %s, hover = %s, visible = %s', selectorEl, inputFocused, hover, visible);
</script>
<div class="selector" bind:this={selectorEl}>
{#if current?.t === 'Address' && inputValue.length > 0}
<div class="input">
<div class="label">
<UpObject link address={String(current.c)} />
</div>
<IconButton name="x" on:click={() => (inputValue = '')} />
</div>
{:else}
<Input
bind:this={input}
bind:value={inputValue}
on:focusChange={(ev) => (inputFocused = ev.detail)}
on:keydown={handleArrowKeys}
{disabled}
{placeholder}
>
<slot name="prefix" slot="prefix" />
</Input>
{/if}
<ul
class="options"
class:visible
on:mouseenter={() => (hover = true)}
on:mouseleave={() => (hover = false)}
bind:this={listEl}
>
{#if updating}
<li><Spinner centered /></li>
{/if}
{#each options.slice(0, MAX_OPTIONS) as option, idx}
<!-- svelte-ignore a11y-no-noninteractive-tabindex a11y-no-noninteractive-element-interactions -->
<li
tabindex="0"
on:click={() => set(option)}
on:mousemove={() => focus()}
on:focus={() => (optionFocusIndex = idx)}
on:blur={() => (optionFocusIndex = -1)}
on:keydown={(ev) => {
if (ev.key === 'Enter') {
set(option);
} else {
handleArrowKeys(ev);
}
}}
>
{#if option.t === 'Address'}
{@const address = option.c}
{#if option.entry}
<UpEntryComponent entry={option.entry} />
{:else}
<UpObject
{address}
labels={option.labels}
on:resolved={(ev) => onAddressResolved(address, ev)}
/>{/if}
{:else if option.t === 'NewAddress'}
<div class="content new">{option.c}</div>
<div class="type">{$i18n.t('Create object')}</div>
{:else if option.t === 'Attribute'}
{#if option.labels?.length}
<div class="content">
{#each option.labels as label}
<div class="label">{label}</div>
{/each}
</div>
<div class="type">{option.name}</div>
{:else}
<div class="content">
{option.name}
</div>
{/if}
{:else if option.t === 'NewAttribute'}
<div class="content">{option.label}</div>
<div class="type">{$i18n.t('Create attribute')} ({option.name})</div>
{:else}
<div class="type">{option.t}</div>
<div class="content">{option.c}</div>
{/if}
</li>
{/each}
</ul>
</div>
<style lang="scss">
.selector {
position: relative;
}
.input {
display: flex;
min-width: 0;
.label {
flex: 1;
min-width: 0;
}
}
.options {
position: absolute;
list-style: none;
margin: 2px 0 0;
padding: 0;
border: 1px solid var(--foreground-lighter);
width: 100%;
border-radius: 4px;
background: var(--background);
box-shadow: 0.2em 0.2em 0.5em rgba(0, 0, 0, 0.3);
// reset
font-size: 1rem;
font-weight: normal;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s;
z-index: 99;
&.visible {
visibility: visible;
opacity: 1;
}
li {
cursor: pointer;
padding: 0.25em;
transition: background-color 0.1s;
&:hover {
background-color: var(--background-lighter);
}
&:focus {
background-color: var(--background-lighter);
outline: none;
}
.type,
.content {
display: inline-block;
}
.type {
opacity: 0.8;
font-size: smaller;
}
.label {
display: inline-block;
}
}
.content.new {
padding: 0.25em;
}
}
</style>