Merge pull request #462 from MareStare/fix/autocomplete-incorrect-match-highlight

Fix invalid highlighting of the prefix match for namespaced tags in autocomplete
This commit is contained in:
liamwhite 2025-03-18 08:53:31 -04:00 committed by GitHub
commit fb963e80b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 288 additions and 137 deletions

View file

@ -5,6 +5,9 @@ extend-ignore-re = [
".*secret_key_base.*", ".*secret_key_base.*",
# Key constraints with encoded names # Key constraints with encoded names
"fk_rails_[a-f0-9]+" "fk_rails_[a-f0-9]+",
]
# Often used in tests as a partial match for `foo`
"fo",
"FO",
]

View file

@ -1,4 +1,4 @@
import { HistorySuggestion } from '../../utils/suggestions'; import { HistorySuggestionComponent } from '../../utils/suggestions-view';
import { InputHistory } from './history'; import { InputHistory } from './history';
import { HistoryStore } from './store'; import { HistoryStore } from './store';
import { AutocompletableInput } from '../input'; import { AutocompletableInput } from '../input';
@ -60,7 +60,7 @@ export function listen() {
* specified as an argument, it will return the maximum number of suggestions * specified as an argument, it will return the maximum number of suggestions
* allowed by the input. * allowed by the input.
*/ */
export function listSuggestions(input: AutocompletableInput, limit?: number): HistorySuggestion[] { export function listSuggestions(input: AutocompletableInput, limit?: number): HistorySuggestionComponent[] {
if (!input.hasHistory()) { if (!input.hasHistory()) {
return []; return [];
} }
@ -70,5 +70,5 @@ export function listSuggestions(input: AutocompletableInput, limit?: number): Hi
return histories return histories
.load(input.historyId) .load(input.historyId)
.listSuggestions(value, limit ?? input.maxSuggestions) .listSuggestions(value, limit ?? input.maxSuggestions)
.map(content => new HistorySuggestion(content, value.length)); .map(content => new HistorySuggestionComponent(content, value.length));
} }

View file

@ -2,13 +2,14 @@ import { LocalAutocompleter } from '../utils/local-autocompleter';
import * as history from './history'; import * as history from './history';
import { AutocompletableInput, TextInputElement } from './input'; import { AutocompletableInput, TextInputElement } from './input';
import { import {
SuggestionsPopup, SuggestionsPopupComponent,
Suggestions, Suggestions,
TagSuggestion, TagSuggestionComponent,
Suggestion, Suggestion,
HistorySuggestion, HistorySuggestionComponent,
ItemSelectedEvent, ItemSelectedEvent,
} from '../utils/suggestions'; } from '../utils/suggestions-view';
import { prefixMatchParts } from '../utils/suggestions-model';
import { $$ } from '../utils/dom'; import { $$ } from '../utils/dom';
import { AutocompleteClient, GetTagSuggestionsRequest } from './client'; import { AutocompleteClient, GetTagSuggestionsRequest } from './client';
import { DebouncedCache } from '../utils/debounced-cache'; import { DebouncedCache } from '../utils/debounced-cache';
@ -33,7 +34,7 @@ function readHistoryConfig() {
class Autocomplete { class Autocomplete {
index: null | 'fetching' | 'unavailable' | LocalAutocompleter = null; index: null | 'fetching' | 'unavailable' | LocalAutocompleter = null;
input: AutocompletableInput | null = null; input: AutocompletableInput | null = null;
popup = new SuggestionsPopup(); popup = new SuggestionsPopupComponent();
client = new AutocompleteClient(); client = new AutocompleteClient();
serverSideTagSuggestions = new DebouncedCache(this.client.getTagSuggestions.bind(this.client)); serverSideTagSuggestions = new DebouncedCache(this.client.getTagSuggestions.bind(this.client));
@ -120,7 +121,7 @@ class Autocomplete {
suggestions.tags = this.index suggestions.tags = this.index
.matchPrefix(activeTerm, input.maxSuggestions - suggestions.history.length) .matchPrefix(activeTerm, input.maxSuggestions - suggestions.history.length)
.map(result => new TagSuggestion({ ...result, matchLength: activeTerm.length })); .map(suggestion => new TagSuggestionComponent(suggestion));
// Used for debugging server-side completions, to ensure local autocomplete // Used for debugging server-side completions, to ensure local autocomplete
// doesn't prevent sever-side completions from being shown. Use these console // doesn't prevent sever-side completions from being shown. Use these console
@ -146,7 +147,11 @@ class Autocomplete {
this.scheduleServerSideSuggestions(activeTerm, suggestions.history); this.scheduleServerSideSuggestions(activeTerm, suggestions.history);
} }
scheduleServerSideSuggestions(this: ActiveAutocomplete, term: string, historySuggestions: HistorySuggestion[]) { scheduleServerSideSuggestions(
this: ActiveAutocomplete,
term: string,
historySuggestions: HistorySuggestionComponent[],
) {
const request: GetTagSuggestionsRequest = { const request: GetTagSuggestionsRequest = {
term, term,
@ -165,13 +170,25 @@ class Autocomplete {
// Truncate the suggestions to the leftover space shared with history suggestions. // Truncate the suggestions to the leftover space shared with history suggestions.
const maxTags = this.input.maxSuggestions - historySuggestions.length; const maxTags = this.input.maxSuggestions - historySuggestions.length;
const tags = response.suggestions.slice(0, maxTags).map( const tags = response.suggestions.slice(0, maxTags).map(suggestion => {
suggestion => // Convert the server-side suggestions into the UI components.
new TagSuggestion({ // We are inferring the match parts assuming it's a prefix match here
...suggestion, // on frontend but best would be if backend would return the match parts
matchLength: term.length, // directly.
}),
); if (suggestion.alias) {
return new TagSuggestionComponent({
alias: prefixMatchParts(suggestion.alias, term),
canonical: suggestion.canonical,
images: suggestion.images,
});
}
return new TagSuggestionComponent({
canonical: prefixMatchParts(suggestion.canonical, term),
images: suggestion.images,
});
});
this.showSuggestions({ this.showSuggestions({
history: historySuggestions, history: historySuggestions,
@ -310,7 +327,7 @@ class Autocomplete {
this.input.element.dispatchEvent(newEvent); this.input.element.dispatchEvent(newEvent);
if (ctrlKey || (suggestion instanceof HistorySuggestion && !shiftKey)) { if (ctrlKey || (suggestion instanceof HistorySuggestionComponent && !shiftKey)) {
// We use `requestSubmit()` instead of `submit()` because it triggers the // We use `requestSubmit()` instead of `submit()` because it triggers the
// 'submit' event on the form. We have a handler subscribed to that event // 'submit' event on the form. We have a handler subscribed to that event
// that records the input's value for history tracking. // that records the input's value for history tracking.
@ -334,7 +351,7 @@ class Autocomplete {
const value = suggestion.value(); const value = suggestion.value();
if (!activeTerm || suggestion instanceof HistorySuggestion) { if (!activeTerm || suggestion instanceof HistorySuggestionComponent) {
element.value = value; element.value = value;
return; return;
} }

View file

@ -2,6 +2,7 @@ import { LocalAutocompleter } from '../local-autocompleter';
import { promises } from 'fs'; import { promises } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { TextDecoder } from 'util'; import { TextDecoder } from 'util';
import { MatchPart } from 'utils/suggestions-model';
describe('LocalAutocompleter', () => { describe('LocalAutocompleter', () => {
let mockData: ArrayBuffer; let mockData: ArrayBuffer;
@ -49,9 +50,16 @@ describe('LocalAutocompleter', () => {
function expectLocalAutocomplete(term: string, topK = 5) { function expectLocalAutocomplete(term: string, topK = 5) {
const localAutocomplete = new LocalAutocompleter(mockData); const localAutocomplete = new LocalAutocompleter(mockData);
const results = localAutocomplete.matchPrefix(term, topK); const results = localAutocomplete.matchPrefix(term, topK);
const joinMatchParts = (parts: MatchPart[]) =>
parts.map(part => (typeof part === 'string' ? part : `{${part.matched}}`)).join('');
const actual = results.map(result => { const actual = results.map(result => {
const canonical = `${result.canonical} (${result.images})`; if (result.alias) {
return result.alias ? `${result.alias} -> ${canonical}` : canonical; return `${joinMatchParts(result.alias)} -> ${result.canonical} (${result.images})`;
}
return `${joinMatchParts(result.canonical)} (${result.images})`;
}); });
return expect(actual); return expect(actual);
@ -64,7 +72,7 @@ describe('LocalAutocompleter', () => {
it('should return suggestions for exact tag name match', () => { it('should return suggestions for exact tag name match', () => {
expectLocalAutocomplete('safe').toMatchInlineSnapshot(` expectLocalAutocomplete('safe').toMatchInlineSnapshot(`
[ [
"safe (6)", "{safe} (6)",
] ]
`); `);
}); });
@ -72,7 +80,7 @@ describe('LocalAutocompleter', () => {
it('should return suggestion for an alias', () => { it('should return suggestion for an alias', () => {
expectLocalAutocomplete('flowers').toMatchInlineSnapshot(` expectLocalAutocomplete('flowers').toMatchInlineSnapshot(`
[ [
"flowers -> flower (1)", "{flowers} -> flower (1)",
] ]
`); `);
}); });
@ -80,7 +88,7 @@ describe('LocalAutocompleter', () => {
it('should prefer canonical tag over an alias when both match', () => { it('should prefer canonical tag over an alias when both match', () => {
expectLocalAutocomplete('flo').toMatchInlineSnapshot(` expectLocalAutocomplete('flo').toMatchInlineSnapshot(`
[ [
"flower (1)", "{flo}wer (1)",
] ]
`); `);
}); });
@ -88,9 +96,9 @@ describe('LocalAutocompleter', () => {
it('should return suggestions sorted by image count', () => { it('should return suggestions sorted by image count', () => {
expectLocalAutocomplete(termStem).toMatchInlineSnapshot(` expectLocalAutocomplete(termStem).toMatchInlineSnapshot(`
[ [
"forest (3)", "{fo}rest (3)",
"fog (1)", "{fo}g (1)",
"force field (1)", "{fo}rce field (1)",
] ]
`); `);
}); });
@ -98,7 +106,7 @@ describe('LocalAutocompleter', () => {
it('should return namespaced suggestions without including namespace', () => { it('should return namespaced suggestions without including namespace', () => {
expectLocalAutocomplete('test').toMatchInlineSnapshot(` expectLocalAutocomplete('test').toMatchInlineSnapshot(`
[ [
"artist:test (1)", "artist:{test} (1)",
] ]
`); `);
}); });
@ -106,7 +114,7 @@ describe('LocalAutocompleter', () => {
it('should return only the required number of suggestions', () => { it('should return only the required number of suggestions', () => {
expectLocalAutocomplete(termStem, 1).toMatchInlineSnapshot(` expectLocalAutocomplete(termStem, 1).toMatchInlineSnapshot(`
[ [
"forest (3)", "{fo}rest (3)",
] ]
`); `);
}); });

View file

@ -0,0 +1,63 @@
import { prefixMatchParts } from '../suggestions-model.ts';
describe('prefixMatchParts', () => {
it('separates the prefix from the plain tag', () => {
expect(prefixMatchParts('foobar', 'foo')).toMatchInlineSnapshot(`
[
{
"matched": "foo",
},
"bar",
]
`);
});
it('separates the prefix from the namespaced tag', () => {
expect(prefixMatchParts('bruh:bar', 'bru')).toMatchInlineSnapshot(`
[
{
"matched": "bru",
},
"h:bar",
]
`);
});
it('separates the prefix after the namespace', () => {
expect(prefixMatchParts('foo:bazz', 'baz')).toMatchInlineSnapshot(`
[
"foo:",
{
"matched": "baz",
},
"z",
]
`);
});
it(`should ignore case when matching`, () => {
expect(prefixMatchParts('FOObar', 'foo')).toMatchInlineSnapshot(`
[
{
"matched": "FOO",
},
"bar",
]
`);
});
it(`should skip empty parts`, () => {
expect(prefixMatchParts('foo', 'foo')).toMatchInlineSnapshot(`
[
{
"matched": "foo",
},
]
`);
expect(prefixMatchParts('foo', 'bar')).toMatchInlineSnapshot(`
[
"foo",
]
`);
});
});

View file

@ -1,29 +1,31 @@
import { import {
SuggestionsPopup, SuggestionsPopupComponent,
TagSuggestion, TagSuggestionComponent,
TagSuggestionParams,
Suggestions, Suggestions,
HistorySuggestion, HistorySuggestionComponent,
ItemSelectedEvent, ItemSelectedEvent,
} from '../suggestions.ts'; } from '../suggestions-view.ts';
import { TagSuggestion } from 'utils/suggestions-model.ts';
import { afterEach } from 'vitest'; import { afterEach } from 'vitest';
import { fireEvent } from '@testing-library/dom'; import { fireEvent } from '@testing-library/dom';
import { assertNotNull } from '../assert.ts'; import { assertNotNull } from '../assert.ts';
const mockedSuggestions: Suggestions = { const mockedSuggestions: Suggestions = {
history: ['foo bar', 'bar baz', 'baz qux'].map(content => new HistorySuggestion(content, 0)), history: ['foo bar', 'bar baz', 'baz qux'].map(content => new HistorySuggestionComponent(content, 0)),
tags: [ tags: [
{ images: 10, canonical: 'artist:assasinmonkey' }, { images: 10, canonical: ['artist:assasinmonkey'] },
{ images: 10, canonical: 'artist:hydrusbeta' }, { images: 10, canonical: ['artist:hydrusbeta'] },
{ images: 10, canonical: 'artist:the sexy assistant' }, { images: 10, canonical: ['artist:the sexy assistant'] },
{ images: 10, canonical: 'artist:devinian' }, { images: 10, canonical: ['artist:devinian'] },
{ images: 10, canonical: 'artist:moe' }, { images: 10, canonical: ['artist:moe'] },
].map(tags => new TagSuggestion({ ...tags, matchLength: 0 })), ].map(suggestion => new TagSuggestionComponent(suggestion)),
}; };
function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] { function mockBaseSuggestionsPopup(
includeMockedSuggestions: boolean = false,
): [SuggestionsPopupComponent, HTMLInputElement] {
const input = document.createElement('input'); const input = document.createElement('input');
const popup = new SuggestionsPopup(); const popup = new SuggestionsPopupComponent();
document.body.append(input); document.body.append(input);
popup.showForElement(input); popup.showForElement(input);
@ -38,7 +40,7 @@ function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [S
const selectedItemClassName = 'autocomplete__item--selected'; const selectedItemClassName = 'autocomplete__item--selected';
describe('Suggestions', () => { describe('Suggestions', () => {
let popup: SuggestionsPopup | undefined; let popup: SuggestionsPopupComponent | undefined;
let input: HTMLInputElement | undefined; let input: HTMLInputElement | undefined;
afterEach(() => { afterEach(() => {
@ -215,31 +217,31 @@ describe('Suggestions', () => {
it('should format suggested tags as tag name and the count', () => { it('should format suggested tags as tag name and the count', () => {
// The snapshots in this test contain a "narrow no-break space" // The snapshots in this test contain a "narrow no-break space"
/* eslint-disable no-irregular-whitespace */ /* eslint-disable no-irregular-whitespace */
expectTagRender({ canonical: 'safe', images: 10 }).toMatchInlineSnapshot(` expectTagRender({ canonical: ['safe'], images: 10 }).toMatchInlineSnapshot(`
{ {
"label": " safe 10", "label": " safe 10",
"value": "safe", "value": "safe",
} }
`); `);
expectTagRender({ canonical: 'safe', images: 10_000 }).toMatchInlineSnapshot(` expectTagRender({ canonical: ['safe'], images: 10_000 }).toMatchInlineSnapshot(`
{ {
"label": " safe 10000", "label": " safe 10000",
"value": "safe", "value": "safe",
} }
`); `);
expectTagRender({ canonical: 'safe', images: 100_000 }).toMatchInlineSnapshot(` expectTagRender({ canonical: ['safe'], images: 100_000 }).toMatchInlineSnapshot(`
{ {
"label": " safe 100000", "label": " safe 100000",
"value": "safe", "value": "safe",
} }
`); `);
expectTagRender({ canonical: 'safe', images: 1000_000 }).toMatchInlineSnapshot(` expectTagRender({ canonical: ['safe'], images: 1000_000 }).toMatchInlineSnapshot(`
{ {
"label": " safe 1000000", "label": " safe 1000000",
"value": "safe", "value": "safe",
} }
`); `);
expectTagRender({ canonical: 'safe', images: 10_000_000 }).toMatchInlineSnapshot(` expectTagRender({ canonical: ['safe'], images: 10_000_000 }).toMatchInlineSnapshot(`
{ {
"label": " safe 10000000", "label": " safe 10000000",
"value": "safe", "value": "safe",
@ -249,7 +251,7 @@ describe('Suggestions', () => {
}); });
it('should display alias -> canonical for aliased tags', () => { it('should display alias -> canonical for aliased tags', () => {
expectTagRender({ images: 10, canonical: 'safe', alias: 'rating:safe' }).toMatchInlineSnapshot( expectTagRender({ images: 10, canonical: 'safe', alias: ['rating:safe'] }).toMatchInlineSnapshot(
` `
{ {
"label": " rating:safe → safe 10", "label": " rating:safe → safe 10",
@ -262,7 +264,7 @@ describe('Suggestions', () => {
}); });
function expectHistoryRender(content: string) { function expectHistoryRender(content: string) {
const suggestion = new HistorySuggestion(content, 0); const suggestion = new HistorySuggestionComponent(content, 0);
const label = suggestion const label = suggestion
.render() .render()
.map(el => el.textContent) .map(el => el.textContent)
@ -272,8 +274,8 @@ function expectHistoryRender(content: string) {
return expect({ label, value }); return expect({ label, value });
} }
function expectTagRender(params: Omit<TagSuggestionParams, 'matchLength'>) { function expectTagRender(params: TagSuggestion) {
const suggestion = new TagSuggestion({ ...params, matchLength: 0 }); const suggestion = new TagSuggestionComponent(params);
const label = suggestion const label = suggestion
.render() .render()
.map(el => el.textContent) .map(el => el.textContent)

View file

@ -61,7 +61,7 @@ export function removeEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E
export function makeEl<Tag extends keyof HTMLElementTagNameMap>( export function makeEl<Tag extends keyof HTMLElementTagNameMap>(
tag: Tag, tag: Tag,
attr?: Partial<HTMLElementTagNameMap[Tag]>, attr?: Partial<HTMLElementTagNameMap[Tag]>,
children: HTMLElement[] = [], children: (HTMLElement | string)[] = [],
): HTMLElementTagNameMap[Tag] { ): HTMLElementTagNameMap[Tag] {
const el = document.createElement(tag); const el = document.createElement(tag);
if (attr) { if (attr) {

View file

@ -1,24 +1,7 @@
// Client-side tag completion. // Client-side tag completion.
import { UniqueHeap } from './unique-heap'; import { UniqueHeap } from './unique-heap';
import store from './store'; import store from './store';
import { prefixMatchParts, TagSuggestion } from './suggestions-model';
export interface Result {
/**
* If present, then this suggestion is for a tag alias.
* If absent, then this suggestion is for the `canonical` tag name.
*/
alias?: null | string;
/**
* The canonical name of the tag (non-alias).
*/
canonical: string;
/**
* Number of images tagged with this tag.
*/
images: number;
}
/** /**
* Opaque, unique pointer to tag data. * Opaque, unique pointer to tag data.
@ -186,7 +169,8 @@ export class LocalAutocompleter {
} }
/** /**
* Perform a binary search to fetch all results matching a condition. * Perform a binary search with a subsequent forward scan to fetch all results
* matching a `compare` condition.
*/ */
private scanResults( private scanResults(
getResult: (i: number) => TagReferenceIndex, getResult: (i: number) => TagReferenceIndex,
@ -234,7 +218,7 @@ export class LocalAutocompleter {
/** /**
* Find the top K results by image count which match the given string prefix. * Find the top K results by image count which match the given string prefix.
*/ */
matchPrefix(prefixStr: string, k: number): Result[] { matchPrefix(prefixStr: string, k: number): TagSuggestion[] {
if (prefixStr.length === 0) { if (prefixStr.length === 0) {
return []; return [];
} }
@ -268,16 +252,20 @@ export class LocalAutocompleter {
return results.topK(k).map((i: TagReferenceIndex) => { return results.topK(k).map((i: TagReferenceIndex) => {
const alias = this.decoder.decode(this.referenceToName(i, false)); const alias = this.decoder.decode(this.referenceToName(i, false));
const canonical = this.decoder.decode(this.referenceToName(i)); const canonical = this.decoder.decode(this.referenceToName(i));
const result: Result = { const images = this.getImageCount(i);
canonical,
images: this.getImageCount(i),
};
if (alias !== canonical) { if (alias === canonical) {
result.alias = alias; return {
canonical: prefixMatchParts(canonical, prefixStr),
images,
};
} }
return result; return {
alias: prefixMatchParts(alias, prefixStr),
canonical,
images,
};
}); });
} }
} }

View file

@ -0,0 +1,78 @@
/**
* Matched string is represented as an array of parts that matched and parts that didn't.
* It is designed to be this much generic to allow for matches in random places in case
* if we decide to support more complex matching.
*
* String parts that didn't match are represented as primitive strings. String parts
* that matched are represented as objects with a `matched` property.
*/
export type MatchPart = string | { matched: string };
interface CanonicalTagSuggestion {
/**
* No alias name is present for the canonical tag. It's declared here explicitly
* to make TypeScript pick up this field as the tagged union discriminator.
*/
alias?: undefined;
/**
* The canonical name of the tag (non-alias).
*/
canonical: MatchPart[];
/**
* Number of images tagged with this tag.
*/
images: number;
}
interface AliasTagSuggestion {
/**
* The alias name of the tag.
*/
alias: MatchPart[];
/**
* The canonical tag the alias points to.
*/
canonical: string;
/**
* Number of images tagged with this tag.
*/
images: number;
}
export type TagSuggestion = CanonicalTagSuggestion | AliasTagSuggestion;
/**
* Infers where the prefix match occurred in the target string according to
* the given prefix. It assumes that `target` either starts with `prefix` or
* contains the `prefix` after the namespace separator.
*/
export function prefixMatchParts(target: string, prefix: string): MatchPart[] {
return prefixMatchPartsImpl(target, prefix).filter(part => typeof part !== 'string' || part.length > 0);
}
function prefixMatchPartsImpl(target: string, prefix: string): MatchPart[] {
const targetLower = target.toLowerCase();
const prefixLower = prefix.toLowerCase();
if (targetLower.startsWith(prefixLower)) {
const matched = target.slice(0, prefix.length);
return [{ matched }, target.slice(matched.length)];
}
const separator = target.indexOf(':');
if (separator < 0) {
return [target];
}
const matchStart = separator + 1;
return [
target.slice(0, matchStart),
{ matched: target.slice(matchStart, matchStart + prefix.length) },
target.slice(matchStart + prefix.length),
];
}

View file

@ -1,69 +1,61 @@
import { makeEl } from './dom.ts'; import { makeEl } from './dom.ts';
import { MatchPart, TagSuggestion } from './suggestions-model.ts';
export interface TagSuggestionParams { export class TagSuggestionComponent {
/** data: TagSuggestion;
* If present, then this suggestion is for a tag alias.
* If absent, then this suggestion is for the `canonical` tag name.
*/
alias?: null | string;
/** constructor(data: TagSuggestion) {
* The canonical name of the tag (non-alias). this.data = data;
*/
canonical: string;
/**
* Number of images tagged with this tag.
*/
images: number;
/**
* Length of the prefix in the suggestion that matches the prefix of the current input.
*/
matchLength: number;
}
export class TagSuggestion {
alias?: null | string;
canonical: string;
images: number;
matchLength: number;
constructor(params: TagSuggestionParams) {
this.alias = params.alias;
this.canonical = params.canonical;
this.images = params.images;
this.matchLength = params.matchLength;
} }
value(): string { value(): string {
return this.canonical; if (typeof this.data.canonical === 'string') {
return this.data.canonical;
}
return this.data.canonical.map(part => (typeof part === 'string' ? part : part.matched)).join('');
} }
render(): HTMLElement[] { render(): HTMLElement[] {
const { alias: aliasName, canonical: canonicalName, images: imageCount } = this; const { data } = this;
const label = aliasName ? `${aliasName}${canonicalName}` : canonicalName;
return [ return [
makeEl('div', { className: 'autocomplete__item__content' }, [ makeEl('div', { className: 'autocomplete__item__content' }, [
makeEl('i', { className: 'fa-solid fa-tag' }), makeEl('i', { className: 'fa-solid fa-tag' }),
makeEl('b', { ' ',
className: 'autocomplete__item__tag__match', ...this.renderLabel(),
textContent: ` ${label.slice(0, this.matchLength)}`,
}),
makeEl('span', {
textContent: label.slice(this.matchLength),
}),
]), ]),
makeEl('span', { makeEl('span', {
className: 'autocomplete__item__tag__count', className: 'autocomplete__item__tag__count',
textContent: ` ${TagSuggestion.formatImageCount(imageCount)}`, textContent: ` ${TagSuggestionComponent.renderImageCount(data.images)}`,
}), }),
]; ];
} }
static formatImageCount(count: number): string { renderLabel(): (HTMLElement | string)[] {
const { data } = this;
if (!data.alias) {
return TagSuggestionComponent.renderMatchParts(data.canonical);
}
return [...TagSuggestionComponent.renderMatchParts(data.alias), `${data.canonical}`];
}
static renderMatchParts(parts: MatchPart[]): (HTMLElement | string)[] {
return parts.map(part => {
if (typeof part === 'string') {
return part;
}
return makeEl('b', {
className: 'autocomplete__item__tag__match',
textContent: part.matched,
});
});
}
static renderImageCount(count: number): string {
// We use the 'fr' (French) number formatting style with space-separated // We use the 'fr' (French) number formatting style with space-separated
// groups of 3 digits. // groups of 3 digits.
const formatter = new Intl.NumberFormat('fr', { useGrouping: true }); const formatter = new Intl.NumberFormat('fr', { useGrouping: true });
@ -72,7 +64,7 @@ export class TagSuggestion {
} }
} }
export class HistorySuggestion { export class HistorySuggestionComponent {
/** /**
* Full query string that was previously searched and retrieved from the history. * Full query string that was previously searched and retrieved from the history.
*/ */
@ -111,11 +103,11 @@ export class HistorySuggestion {
} }
} }
export type Suggestion = TagSuggestion | HistorySuggestion; export type Suggestion = TagSuggestionComponent | HistorySuggestionComponent;
export interface Suggestions { export interface Suggestions {
history: HistorySuggestion[]; history: HistorySuggestionComponent[];
tags: TagSuggestion[]; tags: TagSuggestionComponent[];
} }
export interface ItemSelectedEvent { export interface ItemSelectedEvent {
@ -132,7 +124,7 @@ interface SuggestionItem {
/** /**
* Responsible for rendering the suggestions dropdown. * Responsible for rendering the suggestions dropdown.
*/ */
export class SuggestionsPopup { export class SuggestionsPopupComponent {
/** /**
* Index of the currently selected suggestion. -1 means an imaginary item * Index of the currently selected suggestion. -1 means an imaginary item
* before the first item that represents the state where no item is selected. * before the first item that represents the state where no item is selected.
@ -198,7 +190,7 @@ export class SuggestionsPopup {
} }
} }
setSuggestions(params: Suggestions): SuggestionsPopup { setSuggestions(params: Suggestions): SuggestionsPopupComponent {
this.cursor = -1; this.cursor = -1;
this.items = []; this.items = [];
this.container.innerHTML = ''; this.container.innerHTML = '';
@ -219,7 +211,7 @@ export class SuggestionsPopup {
} }
appendSuggestion(suggestion: Suggestion) { appendSuggestion(suggestion: Suggestion) {
const type = suggestion instanceof TagSuggestion ? 'tag' : 'history'; const type = suggestion instanceof TagSuggestionComponent ? 'tag' : 'history';
const element = makeEl( const element = makeEl(
'div', 'div',
@ -323,7 +315,7 @@ export class SuggestionsPopup {
* Returns the item's prototype that can be viewed as the item's type identifier. * Returns the item's prototype that can be viewed as the item's type identifier.
*/ */
private itemType(index: number) { private itemType(index: number) {
return this.items[index].suggestion instanceof TagSuggestion ? 'tag' : 'history'; return this.items[index].suggestion instanceof TagSuggestionComponent ? 'tag' : 'history';
} }
showForElement(targetElement: HTMLElement) { showForElement(targetElement: HTMLElement) {