Add "and" "ampersand" substitution to fuzzy match
This commit is contained in:
@@ -1121,6 +1121,7 @@ func navigationSection() func() *VNode {
|
|||||||
func searchSection() func() *VNode {
|
func searchSection() func() *VNode {
|
||||||
q := NewSignal("")
|
q := NewSignal("")
|
||||||
hit := NewSignal("")
|
hit := NewSignal("")
|
||||||
|
andAmpQ := NewSignal("")
|
||||||
|
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
options := []string{
|
options := []string{
|
||||||
@@ -1129,6 +1130,20 @@ func searchSection() func() *VNode {
|
|||||||
"SegmentedButtons", "SignaturePad", "TabGroup", "ThemeToggle", "Toast", "ToggleSwitch", "Tooltip",
|
"SegmentedButtons", "SignaturePad", "TabGroup", "ThemeToggle", "Toast", "ToggleSwitch", "Tooltip",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both spellings on purpose: with AndAmpersand on, typing either finds both,
|
||||||
|
// and the exact spelling ranks above the substituted one. "Standard" / "Brand"
|
||||||
|
// hold an "and" that is not the whole word, so it is left untouched.
|
||||||
|
andAmpNames := []string{
|
||||||
|
"First Bank & Trust",
|
||||||
|
"First Bank and Trust Company",
|
||||||
|
"Smith & Wesson Financial",
|
||||||
|
"Johnson and Johnson Federal CU",
|
||||||
|
"Highland Savings & Loan",
|
||||||
|
"Standard Chartered",
|
||||||
|
"Brand Mortgage Group",
|
||||||
|
"AT&T Employees CU",
|
||||||
|
}
|
||||||
|
|
||||||
return docSection("search", "Fuzzy search",
|
return docSection("search", "Fuzzy search",
|
||||||
prose("Subsequence matching with a typo tolerance, scored so the best hit sorts first, and "+
|
prose("Subsequence matching with a typo tolerance, scored so the best hit sorts first, and "+
|
||||||
"the matched characters highlighted in the result. \"atbl\" finds AutoTable; so does "+
|
"the matched characters highlighted in the result. \"atbl\" finds AutoTable; so does "+
|
||||||
@@ -1147,10 +1162,30 @@ func searchSection() func() *VNode {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
prose("Set AndAmpersand and the word \"and\" and the symbol \"&\" match each other, so "+
|
||||||
|
"\"First Bank and Trust\" also finds \"First Bank & Trust\". The exact spelling still wins — "+
|
||||||
|
"the substituted form is a penalized extra pass, not a free swap — and a stray \"and\" inside "+
|
||||||
|
"\"Standard\" or \"Brand\" is left alone."),
|
||||||
|
|
||||||
|
demo("AndAmpersand — type \"first bank and trust\", or \"smith & wesson\"",
|
||||||
|
row("max-w-md",
|
||||||
|
ui.FuzzyMatch(ui.FuzzyMatchProps{
|
||||||
|
Options: andAmpNames,
|
||||||
|
Query: andAmpQ.Get(),
|
||||||
|
AndAmpersand: true,
|
||||||
|
Placeholder: "Search bank names…",
|
||||||
|
MaxResults: 6,
|
||||||
|
ShowScores: true,
|
||||||
|
OnQueryChange: func(v string) { andAmpQ.Set(v) },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
apiTable(
|
apiTable(
|
||||||
apiRow{"RankFuzzyMatches", "The scorer, headless. Use it and render the results yourself."},
|
apiRow{"RankFuzzyMatches", "The scorer, headless. Use it and render the results yourself."},
|
||||||
apiRow{"FuzzySegments", "Splits a result into matched / unmatched runs, for highlighting."},
|
apiRow{"FuzzySegments", "Splits a result into matched / unmatched runs, for highlighting."},
|
||||||
apiRow{"FuzzyMatchTypoTolerant", "One transposition or substitution forgiven."},
|
apiRow{"FuzzyMatchTypoTolerant", "One transposition or substitution forgiven."},
|
||||||
|
apiRow{"FuzzyOptions{AndAmpersand}", "Treat the word \"and\" and \"&\" as interchangeable, exact spelling still first."},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1332,6 +1332,20 @@ const KIT_NAMES = [
|
|||||||
"Tooltip", "TutorialProvider", "SidebarNav", "Chart", "FuzzyMatch",
|
"Tooltip", "TutorialProvider", "SidebarNav", "Chart", "FuzzyMatch",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Both "and" and "&" spellings appear here on purpose: with andAmpersand on, typing
|
||||||
|
// either finds both, and the exact spelling ranks above the substituted one.
|
||||||
|
// "Standard" / "Brand" hold an "and" that is NOT the whole word — left untouched.
|
||||||
|
const AND_AMP_NAMES = [
|
||||||
|
"First Bank & Trust",
|
||||||
|
"First Bank and Trust Company",
|
||||||
|
"Smith & Wesson Financial",
|
||||||
|
"Johnson and Johnson Federal CU",
|
||||||
|
"Highland Savings & Loan",
|
||||||
|
"Standard Chartered",
|
||||||
|
"Brand Mortgage Group",
|
||||||
|
"AT&T Employees CU",
|
||||||
|
];
|
||||||
|
|
||||||
function Search() {
|
function Search() {
|
||||||
const [hit, setHit] = createSignal("");
|
const [hit, setHit] = createSignal("");
|
||||||
|
|
||||||
@@ -1359,6 +1373,25 @@ function Search() {
|
|||||||
and you render them however you like.
|
and you render them however you like.
|
||||||
</p>
|
</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<Prose>
|
||||||
|
Pass <code class="font-mono">andAmpersand</code> and the word "and" and the symbol "&"
|
||||||
|
match each other, so "First Bank and Trust" also finds "First Bank & Trust". The exact
|
||||||
|
spelling still wins — the substituted form is a penalized extra pass, not a free swap — and a
|
||||||
|
stray "and" inside "Standard" or "Brand" is left alone.
|
||||||
|
</Prose>
|
||||||
|
|
||||||
|
<Panel title="andAmpersand — type “first bank and trust”, or “smith & wesson”">
|
||||||
|
<div class="max-w-md">
|
||||||
|
<FuzzyMatch
|
||||||
|
options={AND_AMP_NAMES}
|
||||||
|
andAmpersand
|
||||||
|
placeholder="Search bank names…"
|
||||||
|
maxResults={6}
|
||||||
|
showScores
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ const FUZZY_UNMATCHED_PENALTY = -1;
|
|||||||
const FUZZY_RECURSION_LIMIT = 10;
|
const FUZZY_RECURSION_LIMIT = 10;
|
||||||
const FUZZY_TRANSPOSE_PENALTY = -20;
|
const FUZZY_TRANSPOSE_PENALTY = -20;
|
||||||
const FUZZY_EXACT_SUBSTRING_BONUS = 100;
|
const FUZZY_EXACT_SUBSTRING_BONUS = 100;
|
||||||
|
const FUZZY_AND_AMPERSAND_PENALTY = -20;
|
||||||
|
|
||||||
|
// Options that turn on extra, penalized match passes. Off by default so the base
|
||||||
|
// matcher stays exact-only.
|
||||||
|
export interface FuzzyMatchOptions {
|
||||||
|
// Treat the word "and" and the symbol "&" as interchangeable, so "First Bank
|
||||||
|
// and Trust" also finds "First Bank & Trust" (and vice versa). The exact
|
||||||
|
// spelling still wins — the substituted form is penalized, not free.
|
||||||
|
andAmpersand?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const isLower = (c: string) => c >= "a" && c <= "z";
|
const isLower = (c: string) => c >= "a" && c <= "z";
|
||||||
const isUpper = (c: string) => c >= "A" && c <= "Z";
|
const isUpper = (c: string) => c >= "A" && c <= "Z";
|
||||||
@@ -93,11 +103,26 @@ export function fuzzyMatch(query: string, target: string): FuzzyMatchResult | nu
|
|||||||
return { score, positions: matches };
|
return { score, positions: matches };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alternative spellings of `query` with the word "and" and the symbol "&" swapped
|
||||||
|
// for each other, excluding `query` itself. `\band\b` only rewrites a standalone
|
||||||
|
// "and", so "brand" and "island" are left alone. These are the candidates the
|
||||||
|
// matcher tries (penalized) so "... and ..." can still hit a "... & ..." target
|
||||||
|
// and vice versa, while the exact spelling ranks first.
|
||||||
|
function andAmpersandVariants(query: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const amp = query.replace(/\band\b/gi, "&");
|
||||||
|
if (amp !== query) out.push(amp);
|
||||||
|
const and = query.replace(/&/g, "and");
|
||||||
|
if (and !== query) out.push(and);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Subsequence matching can't tolerate a transposed typo ("teh" vs "the") because
|
// Subsequence matching can't tolerate a transposed typo ("teh" vs "the") because
|
||||||
// the swapped letters violate ordering. So also try every single adjacent-swap
|
// the swapped letters violate ordering. So also try every single adjacent-swap
|
||||||
// variant of the query and keep the best, penalizing transposed hits so exact
|
// variant of the query and keep the best, penalizing transposed hits so exact
|
||||||
// matches still rank first.
|
// matches still rank first. With `opts.andAmpersand`, the "and"/"&" spellings are
|
||||||
export function fuzzyMatchTypoTolerant(query: string, target: string): FuzzyMatchResult | null {
|
// tried the same way — an extra penalized pass, not a free substitution.
|
||||||
|
export function fuzzyMatchTypoTolerant(query: string, target: string, opts?: FuzzyMatchOptions): FuzzyMatchResult | null {
|
||||||
let best = fuzzyMatch(query, target);
|
let best = fuzzyMatch(query, target);
|
||||||
for (let i = 0; i < query.length - 1; i++) {
|
for (let i = 0; i < query.length - 1; i++) {
|
||||||
const swapped = query.slice(0, i) + query[i + 1] + query[i] + query.slice(i + 2);
|
const swapped = query.slice(0, i) + query[i + 1] + query[i] + query.slice(i + 2);
|
||||||
@@ -106,6 +131,14 @@ export function fuzzyMatchTypoTolerant(query: string, target: string): FuzzyMatc
|
|||||||
const score = m.score + FUZZY_TRANSPOSE_PENALTY;
|
const score = m.score + FUZZY_TRANSPOSE_PENALTY;
|
||||||
if (!best || score > best.score) best = { score, positions: m.positions };
|
if (!best || score > best.score) best = { score, positions: m.positions };
|
||||||
}
|
}
|
||||||
|
if (opts?.andAmpersand) {
|
||||||
|
for (const variant of andAmpersandVariants(query)) {
|
||||||
|
const m = fuzzyMatch(variant, target);
|
||||||
|
if (!m) continue;
|
||||||
|
const score = m.score + FUZZY_AND_AMPERSAND_PENALTY;
|
||||||
|
if (!best || score > best.score) best = { score, positions: m.positions };
|
||||||
|
}
|
||||||
|
}
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +163,12 @@ export function fuzzySegments(text: string, positions: number[]): FuzzySegment[]
|
|||||||
|
|
||||||
// Rank `options` against `query`, best score first, with highlight segments.
|
// Rank `options` against `query`, best score first, with highlight segments.
|
||||||
// Returns [] for an empty query. This is the headless entry point.
|
// Returns [] for an empty query. This is the headless entry point.
|
||||||
export function rankFuzzyMatches(query: string, options: string[], maxResults?: number): FuzzyRankedItem[] {
|
export function rankFuzzyMatches(query: string, options: string[], maxResults?: number, opts?: FuzzyMatchOptions): FuzzyRankedItem[] {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return [];
|
if (!q) return [];
|
||||||
const out: FuzzyRankedItem[] = [];
|
const out: FuzzyRankedItem[] = [];
|
||||||
for (const value of options) {
|
for (const value of options) {
|
||||||
const m = fuzzyMatchTypoTolerant(q, value);
|
const m = fuzzyMatchTypoTolerant(q, value, opts);
|
||||||
if (m) out.push({ value, score: m.score, segments: fuzzySegments(value, m.positions) });
|
if (m) out.push({ value, score: m.score, segments: fuzzySegments(value, m.positions) });
|
||||||
}
|
}
|
||||||
out.sort((a, b) => b.score - a.score);
|
out.sort((a, b) => b.score - a.score);
|
||||||
@@ -157,6 +190,8 @@ export interface FuzzyMatchProps {
|
|||||||
// Show each result's match score. Debug aid — off by default.
|
// Show each result's match score. Debug aid — off by default.
|
||||||
showScores?: boolean;
|
showScores?: boolean;
|
||||||
maxResults?: number;
|
maxResults?: number;
|
||||||
|
// Treat "and" and "&" as interchangeable (see FuzzyMatchOptions.andAmpersand).
|
||||||
|
andAmpersand?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
listClass?: string;
|
listClass?: string;
|
||||||
@@ -192,7 +227,7 @@ export function FuzzyMatch(props: FuzzyMatchProps) {
|
|||||||
let dropdownRef: HTMLDivElement | undefined;
|
let dropdownRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
const display = () => props.display ?? "list";
|
const display = () => props.display ?? "list";
|
||||||
const results = createMemo(() => rankFuzzyMatches(query(), props.options, props.maxResults));
|
const results = createMemo(() => rankFuzzyMatches(query(), props.options, props.maxResults, { andAmpersand: props.andAmpersand }));
|
||||||
|
|
||||||
// Show all options (unranked) in list mode before the user types anything,
|
// Show all options (unranked) in list mode before the user types anything,
|
||||||
// so the searchable set is visible up front.
|
// so the searchable set is visible up front.
|
||||||
|
|||||||
@@ -57,8 +57,25 @@ const (
|
|||||||
fuzzyRecursionLimit = 10
|
fuzzyRecursionLimit = 10
|
||||||
fuzzyTransposePenalty = -20
|
fuzzyTransposePenalty = -20
|
||||||
fuzzyExactSubstrBonus = 100
|
fuzzyExactSubstrBonus = 100
|
||||||
|
fuzzyAndAmpersandPen = -20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FuzzyOptions turns on extra, penalized match passes. The zero value is exact-
|
||||||
|
// only, so passing no options leaves the base matcher unchanged.
|
||||||
|
type FuzzyOptions struct {
|
||||||
|
// AndAmpersand treats the word "and" and the symbol "&" as interchangeable,
|
||||||
|
// so "First Bank and Trust" also finds "First Bank & Trust" (and vice versa).
|
||||||
|
// The exact spelling still wins — the substituted form is penalized, not free.
|
||||||
|
AndAmpersand bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzyOpt(opts []FuzzyOptions) FuzzyOptions {
|
||||||
|
if len(opts) > 0 {
|
||||||
|
return opts[0]
|
||||||
|
}
|
||||||
|
return FuzzyOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
func fuzzyIsLower(c rune) bool { return c >= 'a' && c <= 'z' }
|
func fuzzyIsLower(c rune) bool { return c >= 'a' && c <= 'z' }
|
||||||
func fuzzyIsUpper(c rune) bool { return c >= 'A' && c <= 'Z' }
|
func fuzzyIsUpper(c rune) bool { return c >= 'A' && c <= 'Z' }
|
||||||
func fuzzyIsSeparator(c rune) bool { return c == ' ' || c == '_' || c == '-' }
|
func fuzzyIsSeparator(c rune) bool { return c == ' ' || c == '_' || c == '-' }
|
||||||
@@ -147,10 +164,59 @@ func FuzzyMatchOne(query, target string) *FuzzyMatchResult {
|
|||||||
return &FuzzyMatchResult{Score: score, Positions: matches}
|
return &FuzzyMatchResult{Score: score, Positions: matches}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fuzzyIsWordByte(c byte) bool {
|
||||||
|
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c >= 0x80
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzyByteAt(s string, i int) byte {
|
||||||
|
if i < 0 || i >= len(s) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// fuzzyReplaceAndWord replaces every standalone "and" (case-insensitive) in s
|
||||||
|
// with repl, leaving substrings like "brand" or "island" untouched. "and", "&"
|
||||||
|
// and the ASCII separators are all single-byte, so a byte scan suffices.
|
||||||
|
func fuzzyReplaceAndWord(s, repl string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
isAnd := i+3 <= len(s) &&
|
||||||
|
(s[i] == 'a' || s[i] == 'A') &&
|
||||||
|
(s[i+1] == 'n' || s[i+1] == 'N') &&
|
||||||
|
(s[i+2] == 'd' || s[i+2] == 'D')
|
||||||
|
if isAnd && !fuzzyIsWordByte(fuzzyByteAt(s, i-1)) && !fuzzyIsWordByte(fuzzyByteAt(s, i+3)) {
|
||||||
|
b.WriteString(repl)
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fuzzyAndAmpersandVariants returns alternative spellings of query with the word
|
||||||
|
// "and" and the symbol "&" swapped for each other, excluding query itself. These
|
||||||
|
// are the candidates the matcher tries (penalized) so "... and ..." can still hit
|
||||||
|
// a "... & ..." target and vice versa, while the exact spelling ranks first.
|
||||||
|
func fuzzyAndAmpersandVariants(query string) []string {
|
||||||
|
var out []string
|
||||||
|
if amp := fuzzyReplaceAndWord(query, "&"); amp != query {
|
||||||
|
out = append(out, amp)
|
||||||
|
}
|
||||||
|
if and := strings.ReplaceAll(query, "&", "and"); and != query {
|
||||||
|
out = append(out, and)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// FuzzyMatchTypoTolerant also tries every single adjacent-swap variant of the
|
// FuzzyMatchTypoTolerant also tries every single adjacent-swap variant of the
|
||||||
// query (subsequence matching can't tolerate a transposed typo like "teh" vs
|
// query (subsequence matching can't tolerate a transposed typo like "teh" vs
|
||||||
// "the"), penalizing transposed hits so exact matches still rank first.
|
// "the"), penalizing transposed hits so exact matches still rank first. With
|
||||||
func FuzzyMatchTypoTolerant(query, target string) *FuzzyMatchResult {
|
// opts.AndAmpersand set, the "and"/"&" spellings are tried the same way — an
|
||||||
|
// extra penalized pass, not a free substitution.
|
||||||
|
func FuzzyMatchTypoTolerant(query, target string, opts ...FuzzyOptions) *FuzzyMatchResult {
|
||||||
best := FuzzyMatchOne(query, target)
|
best := FuzzyMatchOne(query, target)
|
||||||
qr := []rune(query)
|
qr := []rune(query)
|
||||||
for i := 0; i < len(qr)-1; i++ {
|
for i := 0; i < len(qr)-1; i++ {
|
||||||
@@ -164,6 +230,18 @@ func FuzzyMatchTypoTolerant(query, target string) *FuzzyMatchResult {
|
|||||||
best = &FuzzyMatchResult{Score: score, Positions: m.Positions}
|
best = &FuzzyMatchResult{Score: score, Positions: m.Positions}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if fuzzyOpt(opts).AndAmpersand {
|
||||||
|
for _, variant := range fuzzyAndAmpersandVariants(query) {
|
||||||
|
m := FuzzyMatchOne(variant, target)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := m.Score + fuzzyAndAmpersandPen
|
||||||
|
if best == nil || score > best.Score {
|
||||||
|
best = &FuzzyMatchResult{Score: score, Positions: m.Positions}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,14 +276,14 @@ func FuzzySegments(text string, positions []int) []FuzzySegment {
|
|||||||
// RankFuzzyMatches ranks options against query, best score first, with
|
// RankFuzzyMatches ranks options against query, best score first, with
|
||||||
// highlight segments. Returns nil for an empty query. maxResults <= 0 means no
|
// highlight segments. Returns nil for an empty query. maxResults <= 0 means no
|
||||||
// limit. This is the headless entry point.
|
// limit. This is the headless entry point.
|
||||||
func RankFuzzyMatches(query string, options []string, maxResults int) []FuzzyRankedItem {
|
func RankFuzzyMatches(query string, options []string, maxResults int, opts ...FuzzyOptions) []FuzzyRankedItem {
|
||||||
q := strings.TrimSpace(query)
|
q := strings.TrimSpace(query)
|
||||||
if q == "" {
|
if q == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var out []FuzzyRankedItem
|
var out []FuzzyRankedItem
|
||||||
for _, value := range options {
|
for _, value := range options {
|
||||||
m := FuzzyMatchTypoTolerant(q, value)
|
m := FuzzyMatchTypoTolerant(q, value, opts...)
|
||||||
if m != nil {
|
if m != nil {
|
||||||
out = append(out, FuzzyRankedItem{Value: value, Score: m.Score, Segments: FuzzySegments(value, m.Positions)})
|
out = append(out, FuzzyRankedItem{Value: value, Score: m.Score, Segments: FuzzySegments(value, m.Positions)})
|
||||||
}
|
}
|
||||||
@@ -244,6 +322,7 @@ type FuzzyMatchProps struct {
|
|||||||
Display FuzzyMatchDisplay // default FuzzyDisplayList
|
Display FuzzyMatchDisplay // default FuzzyDisplayList
|
||||||
ShowScores bool
|
ShowScores bool
|
||||||
MaxResults int
|
MaxResults int
|
||||||
|
AndAmpersand bool // treat "and" and "&" as interchangeable (see FuzzyOptions)
|
||||||
Placeholder string
|
Placeholder string
|
||||||
Class string
|
Class string
|
||||||
ListClass string
|
ListClass string
|
||||||
@@ -362,7 +441,7 @@ func FuzzyMatch(p FuzzyMatchProps) *vdom.VNode {
|
|||||||
display = FuzzyDisplayList
|
display = FuzzyDisplayList
|
||||||
}
|
}
|
||||||
|
|
||||||
results := RankFuzzyMatches(p.Query, p.Options, p.MaxResults)
|
results := RankFuzzyMatches(p.Query, p.Options, p.MaxResults, FuzzyOptions{AndAmpersand: p.AndAmpersand})
|
||||||
|
|
||||||
inputMods := []vdom.Mod{
|
inputMods := []vdom.Mod{
|
||||||
vdom.Attr("type", "text"),
|
vdom.Attr("type", "text"),
|
||||||
|
|||||||
Reference in New Issue
Block a user