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

432 lines
11 KiB
Svelte

<script lang="ts">
import { debounce } from "lodash";
import { createEventDispatcher } from "svelte";
import type { UpListing } from "upend";
import type { IValue, VALUE_TYPE } from "upend/types";
import { baseSearchOnce, createLabelled } from "../../util/search";
import UpObject from "../display/UpObject.svelte";
import IconButton from "./IconButton.svelte";
import Input from "./Input.svelte";
const dispatch = createEventDispatcher();
import { matchSorter } from "match-sorter";
import api from "../../lib/api";
import { ATTR_LABEL } from "upend/constants";
const MAX_OPTIONS = 25;
export let type: "attribute" | "value";
export let attributeOptions: string[] | undefined = undefined;
export let valueTypes: VALUE_TYPE[] | undefined = undefined;
export let placeholder = "";
export let disabled = false;
export let attribute: string | undefined = undefined;
export let value: IValue | undefined = undefined;
if (type == "attribute") {
value = {
c: attribute,
t: "String",
};
}
let inputValue = String(value?.c || "");
$: if (value === undefined) inputValue = undefined;
function onInput(ev: CustomEvent<string>) {
if (type == "attribute") {
attribute = ev.detail;
} else {
value = {
t: "String",
c: ev.detail,
};
}
}
interface SelectorOption {
attribute?: {
labels: string[];
name: string;
};
value?: IValue;
labelToCreate?: string;
}
let options: SelectorOption[] = [];
let searchResult: UpListing;
const updateOptions = debounce(async (query: string, doSearch: boolean) => {
switch (type) {
case "attribute": {
const allAttributes = await api.fetchAllAttributes();
const attributes = attributeOptions
? allAttributes.filter((attr) => attributeOptions.includes(attr.name))
: allAttributes;
options = attributes
.filter(
(attr) =>
attr.name.toLowerCase().includes(query.toLowerCase()) ||
attr.labels.some((label) =>
label.toLowerCase().includes(query.toLowerCase())
)
)
.map((attribute) => {
return {
attribute,
};
});
const attributeToCreate = inputValue
.toUpperCase()
.replaceAll(/[^A-Z0-9]/g, "_");
if (
!attributeOptions &&
inputValue &&
!allAttributes.map((attr) => attr.name).includes(attributeToCreate)
) {
options.push({
attribute: {
labels: [],
name: attributeToCreate,
},
labelToCreate: inputValue,
});
}
options = options;
break;
}
case "value": {
options = [];
if (query.length == 0) {
return;
}
if (valueTypes === undefined || valueTypes.includes("Number")) {
const number = parseFloat(query);
if (!Number.isNaN(number)) {
options.push({
value: {
t: "Number",
c: number,
},
});
}
}
if (valueTypes === undefined || valueTypes.includes("String")) {
options.push({
value: {
t: "String",
c: query,
},
});
}
if (valueTypes === undefined || valueTypes.includes("Address")) {
if (doSearch) {
searchResult = await baseSearchOnce(query);
}
let exactHits = Object.entries(addressToLabels)
.filter(([_, labels]) =>
labels
.map((l) => l.toLowerCase())
.includes(inputValue.toLowerCase())
)
.map(([addr, _]) => addr);
if (exactHits.length) {
exactHits.forEach((addr) =>
options.push({
value: {
t: "Address",
c: addr,
},
})
);
} else {
options.push({
labelToCreate: inputValue,
});
}
const validOptions = searchResult.entries
.filter((e) => e.attribute === ATTR_LABEL)
.filter((e) => !exactHits.includes(e.entity));
const sortedOptions = matchSorter(validOptions, inputValue, {
keys: ["value.c"],
});
options.push(
...sortedOptions.map((e) => {
return {
value: {
t: "Address",
c: e.entity,
},
} as SelectorOption;
})
);
}
options = options;
break;
}
}
}, 200);
$: {
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) {
switch (type) {
case "attribute":
if (option.labelToCreate) {
// First, "create" attribute, getting its address.
const [_, address] = await api.putEntry({
entity: { t: "Attribute", c: option.attribute.name },
});
// Second, label it.
await api.putEntityAttribute(address, ATTR_LABEL, {
t: "String",
c: option.labelToCreate,
});
}
attribute = option.attribute.name;
inputValue = option.attribute.name;
break;
case "value":
if (!option.labelToCreate) {
value = option.value;
inputValue = String(option.value.c);
} else {
const addr = await createLabelled(option.labelToCreate);
value = {
t: "Address",
c: addr,
};
inputValue = addr;
}
break;
}
dispatch("input", value);
options = [];
optionFocusIndex = -1;
hover = false;
}
let listEl: HTMLUListElement;
let optionFocusIndex = -1;
function handleArrowKeys(ev: KeyboardEvent) {
if (!options.length) {
return;
}
const optionEls = Array.from(listEl.children) as HTMLLIElement[];
const currentIndex = optionEls.findIndex(
(el) => document.activeElement === el
);
let targetIndex = currentIndex;
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() {
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);
$: dispatch("focus", inputFocused || hover || optionFocusIndex > -1);
</script>
<div class="selector">
{#if value?.t == "Address" && inputValue}
<div class="input">
<div class="label">
<UpObject link address={String(value.c)} />
</div>
<IconButton name="x" on:click={() => (inputValue = "")} />
</div>
{:else}
<Input
bind:this={input}
bind:value={inputValue}
on:input={onInput}
on:focusChange={(ev) => (inputFocused = ev.detail)}
on:keydown={handleArrowKeys}
{disabled}
{placeholder}
/>
{/if}
<ul
class="options"
class:visible
on:mouseenter={() => (hover = true)}
on:mouseleave={() => (hover = false)}
bind:this={listEl}
>
{#each options.slice(0, MAX_OPTIONS) as option, idx}
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<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.attribute}
{#if option.labelToCreate}
<div class="content">{option.labelToCreate}</div>
<div class="type">Create attribute ({option.attribute.name})</div>
{:else if option.attribute.labels.length}
<div class="content">
{#each option.attribute.labels as label}
<div class="label">{label}</div>
{/each}
</div>
<div class="type">{option.attribute.name}</div>
{:else}
<div class="content">
{option.attribute.name}
</div>
{/if}
{:else if option.value}
{#if option.value.t == "Address"}
<UpObject
address={String(option.value.c)}
on:resolved={(ev) =>
onAddressResolved(String(option.value.c), ev)}
/>
{:else}
<div class="type">{option.value.t}</div>
<div class="content">{option.value.c}</div>
{/if}
{:else if option.labelToCreate}
<div class="type">Create object</div>
<div class="content">{option.labelToCreate}</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: 0;
padding: 0;
border: 1px solid var(--foreground-lighter);
width: 100%;
border-radius: 4px;
margin-top: 2px;
background: var(--background);
font-size: smaller;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s;
z-index: 99;
&.visible {
visibility: visible;
opacity: 1;
}
li {
cursor: pointer;
padding: 0.5em;
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;
}
}
}
</style>