586 lines
14 KiB
Svelte
586 lines
14 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 "../../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 "../../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;
|
|
}
|
|
}
|
|
}
|
|
|
|
let current:
|
|
| (SelectorOption & { t: "Address" | "Attribute" | "String" | "Number" })
|
|
| undefined = undefined;
|
|
|
|
export function reset() {
|
|
inputValue = "";
|
|
current = undefined;
|
|
dispatch("input", current);
|
|
}
|
|
|
|
let options: SelectorOption[] = [];
|
|
let searchResult: UpListing | undefined = undefined;
|
|
const updateOptions = debounce(async (query: string, doSearch: boolean) => {
|
|
updating = true;
|
|
let result: SelectorOption[] = [];
|
|
|
|
if (query.length === 0 && emptyOptions !== undefined) {
|
|
options = emptyOptions;
|
|
updating = false;
|
|
return;
|
|
}
|
|
|
|
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);
|
|
} 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: null,
|
|
}),
|
|
);
|
|
} 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) {
|
|
result.push({
|
|
...common,
|
|
labels: [entry.value.c.toString()],
|
|
});
|
|
} else {
|
|
result.push({ ...common, entry });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (types.includes("Attribute")) {
|
|
const allAttributes = await api.fetchAllAttributes();
|
|
const attributes = attributeOptions
|
|
? allAttributes.filter((attr) => attributeOptions.includes(attr.name))
|
|
: allAttributes;
|
|
if (emptyOptions === undefined || query.length > 0) {
|
|
result.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,
|
|
),
|
|
);
|
|
}
|
|
|
|
const attributeToCreate = query
|
|
.toUpperCase()
|
|
.replaceAll(/[^A-Z0-9]/g, "_");
|
|
if (
|
|
!attributeOptions &&
|
|
query &&
|
|
!allAttributes.map((attr) => attr.name).includes(attributeToCreate) &&
|
|
types.includes("NewAttribute")
|
|
) {
|
|
result.push({
|
|
t: "NewAttribute",
|
|
name: attributeToCreate,
|
|
label: query,
|
|
});
|
|
}
|
|
}
|
|
|
|
options = result;
|
|
updating = false;
|
|
}, 200);
|
|
|
|
$: dbg("%o Options: %O", selectorEl, options);
|
|
|
|
$: {
|
|
if (inputFocused) {
|
|
updateOptions.cancel();
|
|
updateOptions(inputValue, true);
|
|
addressToLabels = {};
|
|
}
|
|
}
|
|
|
|
let addressToLabels: { [key: string]: string[] } = {};
|
|
function onAddressResolved(address: string, ev: CustomEvent<string[]>) {
|
|
addressToLabels[address] = ev.detail;
|
|
updateOptions.cancel();
|
|
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 -->
|
|
<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);
|
|
|
|
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>
|