process.env.NODE_ENV = "production"; import preact from "preact"; import { observable } from "mobx"; import { observer } from "./observer"; import { sort } from "socket-function/src/misc"; import { css } from "typesafecss"; import { Input } from "./Input"; import { greenButton, yellowButton } from "./colors"; export type InputOption = { value: T; // Defaults to String(value) label?: preact.ComponentChild; // Defaults to typeof label === "string" ? label : String(value) matchText?: string; }; export type FullInputOption = { value: T; label: preact.ComponentChild; matchText: string; }; @observer export class InputPickerURL extends preact.Component<{ label?: preact.ComponentChild; options: (string | InputOption)[]; allowNonOptions?: boolean; value: { value: string }; }> { render() { let { value, options, ...remaining } = this.props; let values = new Set(value.value.split("|").filter(x => x)); return { values.add(v); value.value = Array.from(values).join("|"); }} removePicked={v => { values.delete(v); value.value = Array.from(values).join("|"); }} options={options.map(x => typeof x === "string" ? { value: x } : x)} />; } } @observer export class InputPicker extends preact.Component<{ label?: preact.ComponentChild; picked: T[]; options: InputOption[]; addPicked: (value: T) => void; removePicked: (value: T) => void; allowNonOptions?: boolean; }> { synced = observable({ pendingText: "", focused: false, }); render() { // Input, and beside it the picked values let resolvedOptions = this.props.options.map(option => { let value = option.value; let label = option.label ?? String(value); let matchText = option.matchText ?? (typeof label === "string" ? label : String(value)); return { value, label, matchText }; }); let optionLookup = new Map(resolvedOptions.map((option) => [option.value, option])); let pickedOptions = this.props.picked.map(x => optionLookup.get(x) || { value: x, label: String(x), matchText: String(x) }); let pendingMatches: FullInputOption[] = []; let pendingTextFull = this.synced.pendingText; let pendingText = pendingTextFull.trim().toLowerCase(); if (pendingText) { pendingMatches = resolvedOptions.filter(option => option.matchText.toLowerCase().includes(pendingText)); sort(pendingMatches, x => x.matchText.startsWith(pendingTextFull) && -10 || x.matchText.startsWith(pendingText) && -9 || x.matchText.toLowerCase().startsWith(pendingTextFull) && -8 || x.matchText.toLowerCase().startsWith(pendingText) && -7 || x.matchText.length ); } else if (this.synced.focused) { pendingMatches = resolvedOptions; } let extra = pendingMatches.length; pendingMatches = pendingMatches.slice(0, 10); extra -= pendingMatches.length; return (
{this.props.label} this.synced.pendingText = x} onFocus={() => this.synced.focused = true} onBlur={() => { this.synced.focused = false; this.synced.pendingText = ""; }} onKeyDown={e => { // On tab, add first in pendingMatches if (e.key === "Tab") { e.preventDefault(); if (pendingMatches.length > 0) { this.props.addPicked(pendingMatches[0].value); this.synced.pendingText = ""; } else if (this.props.allowNonOptions) { this.props.addPicked(this.synced.pendingText as T); this.synced.pendingText = ""; } } else if (e.key === "Enter" && this.props.allowNonOptions) { // HACK: I don't even know, this is just terrible. But it is used to fix some UI where we needed a way to inject text this.props.addPicked(this.synced.pendingText as T); this.synced.pendingText = ""; } }} /> {pendingMatches.length > 0 && (
{pendingMatches.map((option) => ( ))} {extra > 0 && ( )}
)}
{pickedOptions.map((option) => ( ))}
); } }