website: swap ufuzzy for quickmatch

This commit is contained in:
nym21
2026-01-22 18:32:57 +01:00
parent 3c87d36535
commit 6ef43ce7ff
10 changed files with 494 additions and 1500 deletions
+2
View File
@@ -11,3 +11,5 @@ nano.*
worker.*
*.mts
*.cts
*.rs
*.md
-6
View File
@@ -1,6 +0,0 @@
LICENSE
*.json
webcomponent*
README.md
cli*
extras/
-215
View File
@@ -1,215 +0,0 @@
declare class uFuzzy {
constructor(opts?: uFuzzy.Options);
/** search API composed of filter/info/sort, with a info/ranking threshold (1e3) and fast outOfOrder impl */
search(
haystack: string[],
needle: string,
/** limit how many terms will be permuted, default = 0; 5 will result in up to 5! (120) search iterations. be careful with this! */
outOfOrder?: number,
/** default = 1e3 */
infoThresh?: number,
preFiltered?: uFuzzy.HaystackIdxs | null,
): uFuzzy.SearchResult;
/** initial haystack filter, can accept idxs from previous prefix/typeahead match as optimization */
filter(
haystack: string[],
needle: string,
idxs?: uFuzzy.HaystackIdxs,
): uFuzzy.HaystackIdxs | null;
/** collects stats about pre-filtered matches, does additional filtering based on term boundary settings, finds highlight ranges */
info(
idxs: uFuzzy.HaystackIdxs,
haystack: string[],
needle: string,
): uFuzzy.Info;
/** performs final result sorting via Array.sort(), relying on Info */
sort(
info: uFuzzy.Info,
haystack: string[],
needle: string,
): uFuzzy.InfoIdxOrder;
/** utility for splitting needle into terms following defined interSplit/intraSplit opts. useful for out-of-order permutes */
split(needle: string, keepCase?: boolean): uFuzzy.Terms;
/** util for creating out-of-order permutations of a needle terms array */
static permute(arr: unknown[]): unknown[][];
/** util for replacing common diacritics/accents */
static latinize<T extends string[] | string>(strings: T): T;
/** util for highlighting matched substr parts of a result */
static highlight<TAccum = string, TMarkedPart = string>(
match: string,
ranges: number[],
mark?: (part: string, matched: boolean) => TMarkedPart,
accum?: TAccum,
append?: (accum: TAccum, part: TMarkedPart) => TAccum | undefined,
): TAccum;
}
export = uFuzzy;
declare namespace uFuzzy {
/** needle's terms */
export type Terms = string[];
/** subset of idxs of a haystack array */
export type HaystackIdxs = number[];
/** sorted order in which info facets should be iterated */
export type InfoIdxOrder = number[];
export type AbortedResult = [null, null, null];
export type FilteredResult = [uFuzzy.HaystackIdxs, null, null];
export type RankedResult = [
uFuzzy.HaystackIdxs,
uFuzzy.Info,
uFuzzy.InfoIdxOrder,
];
export type SearchResult = FilteredResult | RankedResult | AbortedResult;
/** partial RegExp */
type PartialRegExp = string;
/** what should be considered acceptable term bounds */
export const enum BoundMode {
/** will match 'man' substr anywhere. e.g. tasmania */
Any = 0,
/** will match 'man' at whitespace, punct, case-change, and alpha-num boundaries. e.g. mantis, SuperMan, fooManBar, 0007man */
Loose = 1,
/** will match 'man' at whitespace, punct boundaries only. e.g. mega man, walk_man, man-made, foo.man.bar */
Strict = 2,
}
export const enum IntraMode {
/** allows any number of extra char insertions within a term, but all term chars must be present for a match */
MultiInsert = 0,
/** allows for a single-char substitution, transposition, insertion, or deletion within terms (excluding first and last chars) */
SingleError = 1,
}
export type IntraSliceIdxs = [from: number, to: number];
type CompareFn = (a: string, b: string) => number;
export interface Options {
// whether regexps use a /u unicode flag
unicode?: boolean; // false
/** @deprecated renamed to opts.alpha */
letters?: PartialRegExp | null; // a-z
// regexp character class [] of chars which should be treated as letters (case insensitive)
alpha?: PartialRegExp | null; // a-z
/** term segmentation & punct/whitespace merging */
interSplit?: PartialRegExp; // '[^A-Za-z\\d']+'
intraSplit?: PartialRegExp | null; // '[a-z][A-Z]'
/** inter bounds that will be used to increase lft2/rgt2 info counters */
interBound?: PartialRegExp | null; // '[^A-Za-z\\d]'
/** intra bounds that will be used to increase lft1/rgt1 info counters */
intraBound?: PartialRegExp | null; // '[A-Za-z][0-9]|[0-9][A-Za-z]|[a-z][A-Z]'
/** inter-term modes, during .info() can discard matches when bounds conditions are not met */
interLft?: BoundMode; // 0
interRgt?: BoundMode; // 0
/** allowance between terms */
interChars?: PartialRegExp; // '.'
interIns?: number; // Infinity
/** allowance between chars within terms */
intraChars?: PartialRegExp; // '[a-z\\d]'
intraIns?: number; // 0
/** contractions detection */
intraContr?: PartialRegExp; // "'[a-z]{1,2}\\b"
/** error tolerance mode within terms. will clamp intraIns to 1 when set to SingleError */
intraMode?: IntraMode; // 0
/** which part of each term should tolerate errors (when intraMode: 1) */
intraSlice?: IntraSliceIdxs; // [1, Infinity]
/** max substitutions (when intraMode: 1) */
intraSub?: 0 | 1; // 0
/** max transpositions (when intraMode: 1) */
intraTrn?: 0 | 1; // 0
/** max omissions/deletions (when intraMode: 1) */
intraDel?: 0 | 1; // 0
/** can dynamically adjust error tolerance rules per term in needle (when intraMode: 1) */
intraRules?: (term: string) => {
intraSlice?: IntraSliceIdxs;
intraIns: 0 | 1;
intraSub: 0 | 1;
intraTrn: 0 | 1;
intraDel: 0 | 1;
};
/** post-filters matches during .info() based on cmp of term in needle vs partial match */
intraFilt?: (term: string, match: string, index: number) => boolean; // should this also accept WIP info?
/** default: toLocaleUpperCase() */
toUpper?: (str: string) => string;
/** default: toLocaleLowerCase() */
toLower?: (str: string) => string;
/** final sorting cmp when all other match metrics are equal */
compare?: CompareFn;
sort?: (
info: Info,
haystack: string[],
needle: string,
compare?: CompareFn,
) => InfoIdxOrder;
}
export interface Info {
/** matched idxs from haystack */
idx: HaystackIdxs;
/** match offsets */
start: number[];
/** number of left BoundMode.Strict term boundaries found */
interLft2: number[];
/** number of right BoundMode.Strict term boundaries found */
interRgt2: number[];
/** number of left BoundMode.Loose term boundaries found */
interLft1: number[];
/** number of right BoundMode.Loose term boundaries found */
interRgt1: number[];
/** total number of extra chars matched within all terms. higher = matched terms have more fuzz in them */
intraIns: number[];
/** total number of chars found in between matched terms. higher = terms are more sparse, have more fuzz in between them */
interIns: number[];
/** total number of matched contiguous chars (substrs but not necessarily full terms) */
chars: number[];
/** number of exactly-matched terms (intra = 0) where both lft and rgt landed on a BoundMode.Loose or BoundMode.Strict boundary */
terms: number[];
/** number of needle terms with case-sensitive partial matches */
cases: number[];
/** offset ranges within match for highlighting: [startIdx0, endIdx0, startIdx1, endIdx1,...] */
ranges: number[][];
}
}
export as namespace uFuzzy;
File diff suppressed because it is too large Load Diff
+427
View File
@@ -0,0 +1,427 @@
const DEFAULT_SEPARATORS = "_- ";
const DEFAULT_TRIGRAM_BUDGET = 6;
const DEFAULT_LIMIT = 100;
/**
* Configuration for QuickMatch.
*/
export class QuickMatchConfig {
/** @type {string} Characters used to split items into words */
separators = DEFAULT_SEPARATORS;
/** @type {number} Maximum number of results to return */
limit = DEFAULT_LIMIT;
/** @type {number} Number of trigram lookups for fuzzy matching (0-20) */
trigramBudget = DEFAULT_TRIGRAM_BUDGET;
/**
* Set maximum number of results.
* @param {number} n
*/
withLimit(n) {
this.limit = Math.max(1, n);
return this;
}
/**
* Set trigram budget for fuzzy matching.
* Higher values find more typos but cost more.
* @param {number} n - Budget (0-20, default: 6)
*/
withTrigramBudget(n) {
this.trigramBudget = Math.max(0, Math.min(20, n));
return this;
}
/**
* Set word separator characters.
* @param {string} s - Separator characters (default: '_- ')
*/
withSeparators(s) {
this.separators = s;
return this;
}
}
/**
* Fast fuzzy string matcher using word and trigram indexing.
*/
export class QuickMatch {
/**
* Create a new matcher.
* @param {string[]} items - Items to index (should be lowercase)
* @param {QuickMatchConfig} [config] - Optional configuration
*/
constructor(items, config = new QuickMatchConfig()) {
this.config = config;
this.items = items;
/** @type {Map<string, number[]>} */
this.wordIndex = new Map();
/** @type {Map<string, number[]>} */
this.trigramIndex = new Map();
let maxWordLength = 0;
let maxQueryLength = 0;
let maxWordCount = 0;
const { separators } = config;
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const item = items[itemIndex];
if (item.length > maxQueryLength) {
maxQueryLength = item.length;
}
let wordCount = 0;
let wordStart = 0;
for (let i = 0; i <= item.length; i++) {
const isEndOfWord = i === item.length || separators.includes(item[i]);
if (isEndOfWord && i > wordStart) {
wordCount++;
const word = item.slice(wordStart, i);
if (word.length > maxWordLength) {
maxWordLength = word.length;
}
addToIndex(this.wordIndex, word, itemIndex);
addTrigramsToIndex(this.trigramIndex, word, itemIndex);
wordStart = i + 1;
} else if (isEndOfWord) {
wordStart = i + 1;
}
}
if (wordCount > maxWordCount) {
maxWordCount = wordCount;
}
}
this.maxWordLength = maxWordLength + 4;
this.maxQueryLength = maxQueryLength + 6;
this.maxWordCount = maxWordCount + 2;
}
/**
* Find matching items. Returns items sorted by relevance.
* @param {string} query - Search query
*/
matches(query) {
return this.matchesWith(query, this.config);
}
/**
* Find matching items with custom config. Returns items sorted by relevance.
* @param {string} query - Search query
* @param {QuickMatchConfig} config - Configuration to use
*/
matchesWith(query, config) {
const { limit, trigramBudget, separators } = config;
const normalizedQuery = normalizeQuery(query);
if (!normalizedQuery || normalizedQuery.length > this.maxQueryLength) {
return [];
}
const queryWords = parseWords(
normalizedQuery,
separators,
this.maxWordLength,
);
if (!queryWords.length || queryWords.length > this.maxWordCount) {
return [];
}
const knownWords = [];
const unknownWords = [];
for (const word of queryWords) {
const matchingItems = this.wordIndex.get(word);
if (matchingItems) {
knownWords.push(matchingItems);
} else if (word.length >= 3 && unknownWords.length < trigramBudget) {
unknownWords.push(word);
}
}
const exactMatches = intersectAll(knownWords);
const hasExactMatches = exactMatches.length > 0;
const needsFuzzyMatching = unknownWords.length > 0 && trigramBudget > 0;
if (!needsFuzzyMatching) {
if (!hasExactMatches) return [];
return this.sortedByLength(exactMatches, limit);
}
const scores = new Map();
if (hasExactMatches) {
for (const index of exactMatches) {
scores.set(index, 1);
}
}
const minItemLength = Math.max(0, normalizedQuery.length - 3);
const hitCount = this.scoreByTrigrams({
unknownWords,
budget: trigramBudget,
scores,
hasExactMatches,
minItemLength,
});
const minScoreToInclude = Math.max(1, Math.ceil(hitCount / 2));
return this.rankedResults(scores, minScoreToInclude, limit);
}
/**
* @private
* @param {{unknownWords: string[], budget: number, scores: Map<number, number>, hasExactMatches: boolean, minItemLength: number}} args
*/
scoreByTrigrams({
unknownWords,
budget,
scores,
hasExactMatches,
minItemLength,
}) {
const visitedTrigrams = new Set();
let budgetRemaining = budget;
let hitCount = 0;
outer: for (let round = 0; round < budget; round++) {
for (const word of unknownWords) {
if (budgetRemaining <= 0) break outer;
const position = pickTrigramPosition(word.length, round);
if (position < 0) continue;
const trigram =
word[position] + word[position + 1] + word[position + 2];
if (visitedTrigrams.has(trigram)) continue;
visitedTrigrams.add(trigram);
budgetRemaining--;
const matchingItems = this.trigramIndex.get(trigram);
if (!matchingItems) continue;
hitCount++;
for (const itemIndex of matchingItems) {
if (hasExactMatches) {
const currentScore = scores.get(itemIndex);
if (currentScore !== undefined) {
scores.set(itemIndex, currentScore + 1);
}
} else if (this.items[itemIndex].length >= minItemLength) {
scores.set(itemIndex, (scores.get(itemIndex) || 0) + 1);
}
}
}
}
return hitCount;
}
/**
* @private
* @param {number[]} indices
* @param {number} limit
*/
sortedByLength(indices, limit) {
const { items } = this;
indices.sort((a, b) => items[a].length - items[b].length);
if (indices.length > limit) indices.length = limit;
return indices.map((i) => items[i]);
}
/**
* @private
* @param {Map<number, number>} scores
* @param {number} minScore
* @param {number} limit
*/
rankedResults(scores, minScore, limit) {
const { items } = this;
const results = [];
for (const [index, score] of scores) {
if (score >= minScore) {
results.push({ index, score });
}
}
results.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return items[a.index].length - items[b.index].length;
});
if (results.length > limit) results.length = limit;
return results.map((r) => items[r.index]);
}
}
/** @param {string} query */
function normalizeQuery(query) {
let result = "";
let start = 0;
let end = query.length;
while (start < end && query.charCodeAt(start) <= 32) start++;
while (end > start && query.charCodeAt(end - 1) <= 32) end--;
for (let i = start; i < end; i++) {
const code = query.charCodeAt(i);
if (code >= 128) continue;
result +=
code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : query[i];
}
return result;
}
/**
* @param {string} text
* @param {string} separators
* @param {number} maxLength
*/
function parseWords(text, separators, maxLength) {
/** @type {string[]} */
const words = [];
let start = 0;
for (let i = 0; i <= text.length; i++) {
const isEnd = i === text.length || separators.includes(text[i]);
if (isEnd && i > start) {
const word = text.slice(start, i);
if (word.length <= maxLength && !words.includes(word)) {
words.push(word);
}
start = i + 1;
} else if (isEnd) {
start = i + 1;
}
}
return words;
}
/**
* @param {Map<string, number[]>} index
* @param {string} key
* @param {number} value
*/
function addToIndex(index, key, value) {
const existing = index.get(key);
if (existing) {
existing.push(value);
} else {
index.set(key, [value]);
}
}
/**
* @param {Map<string, number[]>} index
* @param {string} word
* @param {number} itemIndex
*/
function addTrigramsToIndex(index, word, itemIndex) {
if (word.length < 3) return;
for (let i = 0; i <= word.length - 3; i++) {
const trigram = word[i] + word[i + 1] + word[i + 2];
const existing = index.get(trigram);
if (!existing) {
index.set(trigram, [itemIndex]);
} else if (existing[existing.length - 1] !== itemIndex) {
existing.push(itemIndex);
}
}
}
/** @param {number[][]} arrays */
function intersectAll(arrays) {
if (!arrays.length) return [];
let smallestIndex = 0;
for (let i = 1; i < arrays.length; i++) {
if (arrays[i].length < arrays[smallestIndex].length) {
smallestIndex = i;
}
}
const result = arrays[smallestIndex].slice();
for (let i = 0; i < arrays.length && result.length > 0; i++) {
if (i === smallestIndex) continue;
let writeIndex = 0;
for (let j = 0; j < result.length; j++) {
if (binarySearch(arrays[i], result[j])) {
result[writeIndex++] = result[j];
}
}
result.length = writeIndex;
}
return result;
}
/**
* @param {number[]} sortedArray
* @param {number} value
*/
function binarySearch(sortedArray, value) {
let low = 0;
let high = sortedArray.length - 1;
while (low <= high) {
const mid = (low + high) >> 1;
const midValue = sortedArray[mid];
if (midValue === value) return true;
if (midValue < value) low = mid + 1;
else high = mid - 1;
}
return false;
}
/**
* @param {number} wordLength
* @param {number} round
*/
function pickTrigramPosition(wordLength, round) {
const maxPosition = wordLength - 3;
if (maxPosition < 0) return -1;
if (round === 0) return 0;
if (round === 1 && maxPosition > 0) return maxPosition;
if (round === 2 && maxPosition > 1) return maxPosition >> 1;
if (maxPosition <= 2) return -1;
const middle = maxPosition >> 1;
const offset = (round - 2) >> 1;
const position = round & 1 ? Math.max(0, middle - offset) : middle + offset;
if (position === 0 || position >= maxPosition || position === middle) {
return -1;
}
return position;
}
+1 -1
View File
@@ -405,6 +405,6 @@ main() {
# Run the main function with all arguments
# main "$@"
main "@leeoniya/ufuzzy"
main "quickmatch-js"
main "lean-qr"
main "lightweight-charts"
-2
View File
@@ -1,6 +1,4 @@
/**
* @import * as _ from "./modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.d.ts"
*
* @import { IChartApi, ISeriesApi as _ISeriesApi, SeriesDefinition, SingleValueData as _SingleValueData, CandlestickData as _CandlestickData, BaselineData as _BaselineData, HistogramData as _HistogramData, SeriesType as LCSeriesType, IPaneApi, LineSeriesPartialOptions as _LineSeriesPartialOptions, HistogramSeriesPartialOptions as _HistogramSeriesPartialOptions, BaselineSeriesPartialOptions as _BaselineSeriesPartialOptions, CandlestickSeriesPartialOptions as _CandlestickSeriesPartialOptions, WhitespaceData, DeepPartial, ChartOptions, Time, LineData as _LineData, createChart as CreateLCChart, LineStyle, createSeriesMarkers as CreateSeriesMarkers, SeriesMarker, ISeriesMarkersPluginApi } from './modules/lightweight-charts/5.1.0/dist/typings.js'
*
* @import * as Brk from "./modules/brk-client/index.js"
+1
View File
@@ -109,6 +109,7 @@ export function initOptions(brk) {
* @param {Option} option
*/
function selectOption(option) {
if (selected.value === option) return;
pushHistory(option.path);
resetParams(option);
writeToStorage(LS_SELECTED_KEY, JSON.stringify(option.path));
+28 -129
View File
@@ -3,7 +3,7 @@ import {
searchLabelElement,
searchResultsElement,
} from "../utils/elements.js";
import ufuzzy from "../modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.mjs";
import { QuickMatch } from "../modules/quickmatch-js/0.3.1/src/index.js";
/**
* @param {Options} options
@@ -11,148 +11,47 @@ import ufuzzy from "../modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.mjs";
export function initSearch(options) {
console.log("search: init");
const haystack = options.list.map((option) => option.title);
const RESULTS_PER_PAGE = 100;
/**
* @param {uFuzzy.SearchResult} searchResult
* @param {number} pageIndex
*/
function computeResultPage(searchResult, pageIndex) {
/** @type {{ option: Option, title: string }[]} */
let list = [];
let [indexes, _info, order] = searchResult || [null, null, null];
const minIndex = pageIndex * RESULTS_PER_PAGE;
if (indexes?.length) {
const maxIndex = Math.min(
(order || indexes).length - 1,
minIndex + RESULTS_PER_PAGE - 1,
);
list = Array(maxIndex - minIndex + 1);
for (let i = minIndex; i <= maxIndex; i++) {
let index = indexes[i];
const title = haystack[index];
list[i % 100] = {
option: options.list[index],
title,
};
}
}
return list;
}
/** @type {uFuzzy.Options} */
const config = {
intraIns: Infinity,
intraChars: `[a-z\d' ]`,
};
const fuzzyMultiInsert = /** @type {uFuzzy} */ (
ufuzzy({
intraIns: 1,
})
);
const fuzzyMultiInsertFuzzier = /** @type {uFuzzy} */ (ufuzzy(config));
const fuzzySingleError = /** @type {uFuzzy} */ (
ufuzzy({
intraMode: 1,
...config,
})
);
const fuzzySingleErrorFuzzier = /** @type {uFuzzy} */ (
ufuzzy({
intraMode: 1,
...config,
})
const haystack = options.list.map((option) => option.title.toLowerCase());
const titleToOption = new Map(
options.list.map((option) => [option.title.toLowerCase(), option]),
);
const matcher = new QuickMatch(haystack);
function inputEvent() {
const needle = /** @type {string} */ (searchInput.value);
const needle = /** @type {string} */ (searchInput.value).trim();
searchResultsElement.scrollTo({
top: 0,
});
searchResultsElement.scrollTo({ top: 0 });
searchResultsElement.innerHTML = "";
if (!needle) {
searchResultsElement.innerHTML = "";
if (needle.length < 3) {
const li = window.document.createElement("li");
li.textContent = 'e.g. "BTC"';
li.style.color = "var(--off-color)";
searchResultsElement.appendChild(li);
return;
}
const outOfOrder = 5;
const infoThresh = 5_000;
const matches = matcher.matches(needle);
let result = fuzzyMultiInsert?.search(
haystack,
needle,
undefined,
infoThresh,
);
if (!result?.[0]?.length || !result?.[1]) {
result = fuzzyMultiInsert?.search(
haystack,
needle,
outOfOrder,
infoThresh,
);
if (!matches.length) {
const li = window.document.createElement("li");
li.textContent = "No results";
li.style.color = "var(--off-color)";
searchResultsElement.appendChild(li);
return;
}
if (!result?.[0]?.length || !result?.[1]) {
result = fuzzySingleError?.search(
haystack,
needle,
outOfOrder,
infoThresh,
);
}
matches.forEach((title) => {
const option = titleToOption.get(title);
if (!option) return;
if (!result?.[0]?.length || !result?.[1]) {
result = fuzzySingleErrorFuzzier?.search(
haystack,
needle,
outOfOrder,
infoThresh,
);
}
if (!result?.[0]?.length || !result?.[1]) {
result = fuzzyMultiInsertFuzzier?.search(
haystack,
needle,
undefined,
infoThresh,
);
}
if (!result?.[0]?.length || !result?.[1]) {
result = fuzzyMultiInsertFuzzier?.search(
haystack,
needle,
outOfOrder,
infoThresh,
);
}
searchResultsElement.innerHTML = "";
const list = computeResultPage(result, 0);
list.forEach(({ option, title }) => {
const li = window.document.createElement("li");
searchResultsElement.appendChild(li);
const element = options.createOptionElement({
option,
name: title,
name: option.title,
});
if (element) {
@@ -161,11 +60,11 @@ export function initSearch(options) {
});
}
if (searchInput.value) {
inputEvent();
}
inputEvent();
searchInput.addEventListener("input", inputEvent);
const len = searchInput.value.length;
searchInput.setSelectionRange(len, len);
}
document.addEventListener("keydown", (e) => {
+35 -100
View File
@@ -165,52 +165,6 @@ export function createLabeledInput({
};
}
/**
* @param {HTMLElement} parent
* @param {HTMLElement} child
* @param {number} index
*/
export function insertElementAtIndex(parent, child, index) {
if (!index) index = 0;
if (index >= parent.children.length) {
parent.appendChild(child);
} else {
parent.insertBefore(child, parent.children[index]);
}
}
/**
* @param {string} url
* @param {boolean} [targetBlank]
*/
export function open(url, targetBlank) {
console.log(`open: ${url}`);
const a = window.document.createElement("a");
window.document.body.append(a);
a.href = url;
if (targetBlank) {
a.target = "_blank";
a.rel = "noopener noreferrer";
}
a.click();
a.remove();
}
/**
* @param {string} href
*/
export function importStyle(href) {
const link = document.createElement("link");
link.href = href;
link.type = "text/css";
link.rel = "stylesheet";
link.media = "screen,print";
const head = window.document.getElementsByTagName("head")[0];
head.appendChild(link);
return link;
}
/**
* @template T
@@ -233,9 +187,6 @@ export function createRadios({
const field = window.document.createElement("div");
field.classList.add("field");
const div = window.document.createElement("div");
field.append(div);
const initialKey = toKey(initialValue);
/** @param {string} key */
@@ -245,7 +196,7 @@ export function createRadios({
if (choices.length === 1) {
const span = window.document.createElement("span");
span.textContent = toLabel(choices[0]);
div.append(span);
field.append(span);
} else {
const fieldId = id ?? "";
choices.forEach((choice) => {
@@ -261,7 +212,7 @@ export function createRadios({
const text = window.document.createTextNode(choiceLabel);
label.append(text);
div.append(label);
field.append(label);
});
field.addEventListener("change", (event) => {
@@ -297,9 +248,8 @@ export function createSelect({
? unsortedChoices.toSorted((a, b) => toLabel(a).localeCompare(toLabel(b)))
: unsortedChoices;
const select = window.document.createElement("select");
select.id = id ?? "";
select.name = id ?? "";
const field = window.document.createElement("div");
field.classList.add("field");
const initialKey = toKey(initialValue);
@@ -307,21 +257,39 @@ export function createSelect({
const fromKey = (key) =>
choices.find((c) => toKey(c) === key) ?? initialValue;
choices.forEach((choice) => {
const option = window.document.createElement("option");
option.value = toKey(choice);
option.textContent = toLabel(choice);
if (toKey(choice) === initialKey) {
option.selected = true;
if (choices.length === 1) {
const span = window.document.createElement("span");
span.textContent = toLabel(choices[0]);
field.append(span);
} else {
const select = window.document.createElement("select");
select.id = id ?? "";
select.name = id ?? "";
field.append(select);
choices.forEach((choice) => {
const option = window.document.createElement("option");
option.value = toKey(choice);
option.textContent = toLabel(choice);
if (toKey(choice) === initialKey) {
option.selected = true;
}
select.append(option);
});
select.addEventListener("change", () => {
onChange?.(fromKey(select.value));
});
const remaining = choices.length - 1;
if (remaining > 0) {
const small = window.document.createElement("small");
small.textContent = `+${remaining}`;
field.append(small);
}
select.append(option);
});
}
select.addEventListener("change", () => {
onChange?.(fromKey(select.value));
});
return select;
return field;
}
/**
@@ -361,39 +329,6 @@ export function createOption(arg) {
}
/**
* @param {Object} args
* @param {string} args.title
* @param {string} args.description
* @param {HTMLElement} args.input
*/
export function createFieldElement({ title, description, input }) {
const div = window.document.createElement("div");
const label = window.document.createElement("label");
div.append(label);
const titleElement = window.document.createElement("span");
titleElement.innerHTML = title;
label.append(titleElement);
const descriptionElement = window.document.createElement("small");
descriptionElement.innerHTML = description;
label.append(descriptionElement);
div.append(input);
const forId = input.id || input.firstElementChild?.id;
if (!forId) {
console.log(input);
throw `Input should've an ID`;
}
label.htmlFor = forId;
return div;
}
/**
* @param {'left' | 'bottom' | 'top' | 'right'} position