@remit/ui 0.0.104 → 0.0.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.104",
3
+ "version": "0.0.105",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -90,7 +90,9 @@ export function matchesBriefFilters(
90
90
  * The attribute chips are either this component's own or entirely the
91
91
  * consumer's. A consumer narrowing the same rows on a second surface (the phone
92
92
  * search takeover) holds the set so both surfaces answer to one selection, and
93
- * takes every control over it with the set.
93
+ * takes every control over it with the set. `onClearFilters` is then the whole
94
+ * of Clear, category scope included — one handler reading one state, rather
95
+ * than two reading the same one and racing to write it.
94
96
  */
95
97
  export type BriefFilterControl =
96
98
  | {
@@ -236,11 +238,11 @@ export function BriefSections({
236
238
  const sheetFilters = briefFilterChips;
237
239
 
238
240
  const clearFilters = () => {
239
- onSelectBriefCategory?.("all");
240
241
  if (onClearFilters) {
241
242
  onClearFilters();
242
243
  return;
243
244
  }
245
+ onSelectBriefCategory?.("all");
244
246
  setOwnFilters(new Set());
245
247
  };
246
248
 
@@ -353,7 +353,7 @@ export const NothingToConvert: Story = {
353
353
  initialValue="has:attachment"
354
354
  sections={resultSections}
355
355
  preset="inbox"
356
- makeFilterBlockedReason="Add a sender or words to filter on"
356
+ makeFilterBlockedReason="Has attachment isn't a filter condition — add a sender or words to filter on"
357
357
  />
358
358
  ),
359
359
  };
@@ -6,6 +6,7 @@ import {
6
6
  DROPPED_SEMANTIC_COPY,
7
7
  droppedFacetsCopy,
8
8
  hasConversionNotice,
9
+ makeFilterBlockedCopy,
9
10
  scopedOutCopy,
10
11
  } from "./search-conversion.js";
11
12
  import { SearchConversionNoticeView } from "./search-conversion-notice.js";
@@ -48,6 +49,29 @@ describe("search-conversion copy", () => {
48
49
  assert.equal(hasConversionNotice({ droppedFacets: ["Unread"] }), true);
49
50
  assert.equal(hasConversionNotice({ droppedSemantic: true }), true);
50
51
  });
52
+
53
+ it("names the facets a chip-composed query is made of, not just the gap", () => {
54
+ const copy = makeFilterBlockedCopy(["Unread", "Category: Newsletter"]);
55
+ assert.match(
56
+ copy,
57
+ /Unread and Category: Newsletter aren't filter conditions/,
58
+ );
59
+ assert.match(copy, /add a sender or words to filter on/);
60
+ });
61
+
62
+ it("reads singular for one facet", () => {
63
+ assert.match(
64
+ makeFilterBlockedCopy(["Unread"]),
65
+ /^Unread isn't a filter condition — /,
66
+ );
67
+ });
68
+
69
+ it("asks for what is missing when no facet is what is in the way", () => {
70
+ assert.equal(
71
+ makeFilterBlockedCopy([]),
72
+ "Add a sender or words to filter on",
73
+ );
74
+ });
51
75
  });
52
76
 
53
77
  describe("SearchConversionNoticeView", () => {
@@ -50,6 +50,24 @@ export function droppedFacetsCopy(facets: string[]): string {
50
50
  } left out — the filter still matches everything else you searched for.`;
51
51
  }
52
52
 
53
+ /** What a query needs before there is a rule to open on. */
54
+ const NEEDS_A_CLAUSE = "add a sender or words to filter on";
55
+
56
+ /**
57
+ * Why the current query has no filter in it. A query narrowed only by chips —
58
+ * ticked in the brief's filter panel, which writes them into the query as terms
59
+ * — is all attribute facets and no clause, so the reason names the facets that
60
+ * cannot be one instead of asking for something the user just supplied.
61
+ */
62
+ export function makeFilterBlockedCopy(droppedFacets: string[]): string {
63
+ if (droppedFacets.length === 0)
64
+ return `${NEEDS_A_CLAUSE[0].toUpperCase()}${NEEDS_A_CLAUSE.slice(1)}`;
65
+ const verb = droppedFacets.length === 1 ? "isn't" : "aren't";
66
+ const noun =
67
+ droppedFacets.length === 1 ? "a filter condition" : "filter conditions";
68
+ return `${joinFacets(droppedFacets)} ${verb} ${noun} — ${NEEDS_A_CLAUSE}`;
69
+ }
70
+
53
71
  /**
54
72
  * States the filter is literal-only, so the search's similar-mail reach is not
55
73
  * carried (RFC 038 D5). Shown only where that reach existed — a capable search
@@ -1,5 +1,6 @@
1
1
  import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
+ import { makeFilterBlockedCopy } from "./search-conversion.js";
3
4
  import type { SearchResult } from "./search-result-row.js";
4
5
  import { type SearchResultSection, SearchResults } from "./search-results.js";
5
6
 
@@ -201,7 +202,7 @@ export const MakeFilterBlocked: Story = {
201
202
  sections={resultSections}
202
203
  makeFilter={{
203
204
  onClick: () => {},
204
- blockedReason: "Add a sender or words to filter on",
205
+ blockedReason: makeFilterBlockedCopy(["Has attachment"]),
205
206
  }}
206
207
  />
207
208
  ),
package/src/index.ts CHANGED
@@ -502,6 +502,7 @@ export {
502
502
  DROPPED_SEMANTIC_COPY,
503
503
  droppedFacetsCopy,
504
504
  hasConversionNotice,
505
+ makeFilterBlockedCopy,
505
506
  type SearchConversionNotice,
506
507
  scopedOutCopy,
507
508
  } from "./components/search-conversion.js";
@@ -678,6 +679,17 @@ export {
678
679
  formatByteSize,
679
680
  sanitizeAttachmentFilename,
680
681
  } from "./lib/attachment-file.js";
682
+ export {
683
+ briefChipCategory,
684
+ briefChipFilters,
685
+ briefFilterHasTerm,
686
+ briefQueryCategory,
687
+ briefQueryFilters,
688
+ briefQueryIsActive,
689
+ clearBriefFiltersInQuery,
690
+ setBriefCategoryInQuery,
691
+ toggleBriefFilterInQuery,
692
+ } from "./lib/brief-filter-query.js";
681
693
  export {
682
694
  buildCidResolver,
683
695
  type CidResolvableBodyPart,
@@ -775,6 +787,14 @@ export {
775
787
  useRovingFocus,
776
788
  } from "./lib/roving-focus.js";
777
789
  export { type RuleNameParts, suggestRuleName } from "./lib/rule-name.js";
790
+ export {
791
+ quoteSearchTokenValue,
792
+ type SearchQueryWord,
793
+ type SearchTermParts,
794
+ searchTokenTerm,
795
+ splitSearchTerm,
796
+ splitSearchWords,
797
+ } from "./lib/search-query-words.js";
778
798
  export {
779
799
  type DroppedFacet,
780
800
  type DroppedFacetType,
@@ -0,0 +1,200 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { BriefFilterId } from "../components/brief-sections.js";
4
+ import {
5
+ briefChipCategory,
6
+ briefChipFilters,
7
+ briefFilterHasTerm,
8
+ briefQueryCategory,
9
+ briefQueryFilters,
10
+ clearBriefFiltersInQuery,
11
+ setBriefCategoryInQuery,
12
+ toggleBriefFilterInQuery,
13
+ } from "./brief-filter-query.js";
14
+
15
+ const ids = (filters: ReadonlySet<BriefFilterId>): BriefFilterId[] =>
16
+ [...filters].sort();
17
+
18
+ describe("ticking a chip while a search is on", () => {
19
+ it("writes its term into the query, where it can be read", () => {
20
+ assert.equal(
21
+ toggleBriefFilterInQuery("Odido", "unread"),
22
+ "Odido is:unread",
23
+ );
24
+ });
25
+
26
+ it("writes the attachment term the same way", () => {
27
+ assert.equal(
28
+ toggleBriefFilterInQuery("Odido", "attachment"),
29
+ "Odido has:attachment",
30
+ );
31
+ });
32
+
33
+ it("takes the term back out when the chip is unticked", () => {
34
+ assert.equal(
35
+ toggleBriefFilterInQuery("Odido is:unread", "unread"),
36
+ "Odido",
37
+ );
38
+ });
39
+
40
+ it("leaves a chips-only query behind once the words are deleted", () => {
41
+ assert.equal(toggleBriefFilterInQuery("", "unread"), "is:unread");
42
+ });
43
+
44
+ it("says a chip has no term rather than writing one that means nothing", () => {
45
+ assert.equal(toggleBriefFilterInQuery("Odido", "contacts"), undefined);
46
+ assert.equal(toggleBriefFilterInQuery("Odido", "today"), undefined);
47
+ assert.equal(briefFilterHasTerm("contacts"), false);
48
+ assert.equal(briefFilterHasTerm("today"), false);
49
+ });
50
+ });
51
+
52
+ describe("scoping to a category while a search is on", () => {
53
+ it("writes the category term", () => {
54
+ assert.equal(
55
+ setBriefCategoryInQuery("Odido", "newsletter"),
56
+ "Odido category:newsletter",
57
+ );
58
+ });
59
+
60
+ it("swaps one category for another rather than stacking them", () => {
61
+ assert.equal(
62
+ setBriefCategoryInQuery("Odido category:newsletter", "marketing"),
63
+ "Odido category:marketing",
64
+ );
65
+ });
66
+
67
+ it("takes the term out again when the scope goes back to all", () => {
68
+ assert.equal(
69
+ setBriefCategoryInQuery("Odido category:newsletter", "all"),
70
+ "Odido",
71
+ );
72
+ });
73
+ });
74
+
75
+ describe("a term edited or deleted by hand", () => {
76
+ it("ticks the chip it names", () => {
77
+ assert.deepEqual(ids(briefQueryFilters("Odido is:unread")), ["unread"]);
78
+ });
79
+
80
+ it("reads the same whatever case it is typed in", () => {
81
+ assert.deepEqual(ids(briefQueryFilters("Odido IS:Unread")), ["unread"]);
82
+ });
83
+
84
+ it("unticks the chip once it is deleted", () => {
85
+ assert.deepEqual(ids(briefQueryFilters("Odido")), []);
86
+ });
87
+
88
+ it("scopes the category the term names, alias included", () => {
89
+ assert.equal(briefQueryCategory("Odido category:newsletter"), "newsletter");
90
+ assert.equal(
91
+ briefQueryCategory("Odido category:unclassified"),
92
+ "uncategorized",
93
+ );
94
+ });
95
+
96
+ it("leaves the scope alone for a category nobody has", () => {
97
+ assert.equal(briefQueryCategory("Odido category:nonsense"), "all");
98
+ });
99
+
100
+ it("never reads its own word as a category", () => {
101
+ assert.equal(briefQueryCategory("category:all"), "all");
102
+ });
103
+
104
+ it("keeps the words the query is otherwise made of", () => {
105
+ assert.equal(
106
+ clearBriefFiltersInQuery(
107
+ "Odido is:unread category:newsletter from:a@b.c",
108
+ ),
109
+ "Odido from:a@b.c",
110
+ );
111
+ });
112
+ });
113
+
114
+ /**
115
+ * Terms are cut the way the parser cuts them. A splitter that broke on
116
+ * whitespace alone read a facet inside a quoted value as a facet of its own,
117
+ * and missed a quoted one that the parser applies — so the chip row disagreed
118
+ * with the rows on screen, and editing reached inside what the user typed.
119
+ */
120
+ describe("a quoted value", () => {
121
+ it("keeps a facet spelled inside it out of the chips", () => {
122
+ assert.deepEqual(ids(briefQueryFilters('subject:"a is:unread b"')), []);
123
+ });
124
+
125
+ it("is left whole when a chip is ticked beside it", () => {
126
+ assert.equal(
127
+ toggleBriefFilterInQuery('subject:"a is:unread b"', "unread"),
128
+ 'subject:"a is:unread b" is:unread',
129
+ );
130
+ });
131
+
132
+ it("ticks the chip when it is the facet's own value", () => {
133
+ assert.deepEqual(ids(briefQueryFilters('is:"unread"')), ["unread"]);
134
+ });
135
+
136
+ it("is removed by unticking rather than joined by a second term", () => {
137
+ assert.equal(toggleBriefFilterInQuery('is:"unread"', "unread"), "");
138
+ });
139
+
140
+ it("scopes the category it names", () => {
141
+ assert.equal(briefQueryCategory('category:"Newsletter"'), "newsletter");
142
+ });
143
+
144
+ // Two category terms AND to nothing (`matchesSearchTokens`), so a category
145
+ // pill that left the old one behind emptied the list on a click.
146
+ it("leaves one category term behind when another is picked", () => {
147
+ assert.equal(
148
+ setBriefCategoryInQuery('category:"Newsletter"', "marketing"),
149
+ "category:marketing",
150
+ );
151
+ });
152
+
153
+ it("is cleared with the rest of the chip terms", () => {
154
+ assert.equal(
155
+ clearBriefFiltersInQuery('Odido is:"unread" category:"Newsletter"'),
156
+ "Odido",
157
+ );
158
+ });
159
+ });
160
+
161
+ describe("the chips the panel shows ticked", () => {
162
+ it("are the panel's own while nothing is being searched", () => {
163
+ assert.deepEqual(
164
+ ids(briefChipFilters({ query: "", ownFilters: new Set(["unread"]) })),
165
+ ["unread"],
166
+ );
167
+ assert.equal(
168
+ briefChipCategory({ query: "", ownCategory: "newsletter" }),
169
+ "newsletter",
170
+ );
171
+ });
172
+
173
+ it("are the query's terms while a search is on", () => {
174
+ assert.deepEqual(
175
+ ids(
176
+ briefChipFilters({
177
+ query: "Odido has:attachment",
178
+ ownFilters: new Set(["unread"]),
179
+ }),
180
+ ),
181
+ ["attachment"],
182
+ );
183
+ assert.equal(
184
+ briefChipCategory({ query: "Odido", ownCategory: "newsletter" }),
185
+ "all",
186
+ );
187
+ });
188
+
189
+ it("carry the chips the vocabulary cannot spell across a search", () => {
190
+ assert.deepEqual(
191
+ ids(
192
+ briefChipFilters({
193
+ query: "Odido is:unread",
194
+ ownFilters: new Set(["today", "contacts"]),
195
+ }),
196
+ ),
197
+ ["contacts", "today", "unread"],
198
+ );
199
+ });
200
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The brief's chips as terms of the search query (#460).
3
+ *
4
+ * While something is being searched, a chip and a typed term are the same
5
+ * thing: ticking "Unread" over `Odido` leaves `Odido is:unread` in the field,
6
+ * which the user can read, edit and delete, and deleting it unticks the chip.
7
+ * The vocabulary is the one the search field already parses — `is:unread`,
8
+ * `has:attachment`, `category:<id>` — so a chip writes a term the engines
9
+ * already honour rather than a second, invisible filter stack over the same
10
+ * rows.
11
+ *
12
+ * Two of the brief's chips have no term in that vocabulary: "From contacts"
13
+ * matches on sender trust, which no facet names, and "Today" is a relative
14
+ * window where the vocabulary carries only absolute `before:`/`after:` dates.
15
+ * Those two keep narrowing from the panel's own state, which is why
16
+ * {@link briefChipFilters} carries them across a search rather than dropping
17
+ * them.
18
+ *
19
+ * Terms are cut and read with `search-query-words.ts`, the splitter the token
20
+ * parser itself uses, so a chip ticks for exactly what the parser applies:
21
+ * `is:"unread"` is the unread facet, and an `is:unread` inside a quoted value
22
+ * belongs to that value.
23
+ */
24
+
25
+ import {
26
+ type BriefCategoryFilter,
27
+ isBriefCategory,
28
+ } from "../components/app-shell-types.js";
29
+ import type { BriefFilterId } from "../components/brief-sections.js";
30
+ import {
31
+ searchTokenTerm,
32
+ splitSearchTerm,
33
+ splitSearchWords,
34
+ } from "./search-query-words.js";
35
+
36
+ interface FilterTerm {
37
+ id: BriefFilterId;
38
+ name: string;
39
+ value: string;
40
+ }
41
+
42
+ const FILTER_TERMS: readonly FilterTerm[] = [
43
+ { id: "unread", name: "is", value: "unread" },
44
+ { id: "attachment", name: "has", value: "attachment" },
45
+ ];
46
+
47
+ const CATEGORY_TERM_NAME = "category";
48
+
49
+ /** Category spellings beyond the ids themselves, as the token parser reads them. */
50
+ const CATEGORY_ALIASES: Record<string, BriefCategoryFilter> = {
51
+ unclassified: "uncategorized",
52
+ };
53
+
54
+ const words = (query: string): string[] =>
55
+ splitSearchWords(query).map((word) => word.raw);
56
+
57
+ const rejoin = (kept: string[]): string => kept.join(" ");
58
+
59
+ /**
60
+ * The chip a term names, read the way the parser reads it: `is:"unread"` is the
61
+ * unread facet, and an `is:unread` sitting inside a quoted value is not a term
62
+ * at all.
63
+ */
64
+ const filterOfWord = (word: string): BriefFilterId | undefined => {
65
+ const parts = splitSearchTerm(word);
66
+ if (!parts) return undefined;
67
+ return FILTER_TERMS.find(
68
+ (term) =>
69
+ term.name === parts.name && term.value === parts.value.toLowerCase(),
70
+ )?.id;
71
+ };
72
+
73
+ const categoryOfWord = (word: string): BriefCategoryFilter | undefined => {
74
+ const parts = splitSearchTerm(word);
75
+ if (!parts || parts.name !== CATEGORY_TERM_NAME) return undefined;
76
+ const value = parts.value.toLowerCase();
77
+ const alias = CATEGORY_ALIASES[value];
78
+ if (alias) return alias;
79
+ if (!isBriefCategory(value) || value === "all") return undefined;
80
+ return value;
81
+ };
82
+
83
+ const termFor = (id: BriefFilterId): FilterTerm | undefined =>
84
+ FILTER_TERMS.find((term) => term.id === id);
85
+
86
+ /** Whether a chip is one the search vocabulary can express. */
87
+ export const briefFilterHasTerm = (id: BriefFilterId): boolean =>
88
+ termFor(id) !== undefined;
89
+
90
+ /** The chips a query's own terms tick. */
91
+ export const briefQueryFilters = (
92
+ query: string,
93
+ ): ReadonlySet<BriefFilterId> => {
94
+ const ticked = new Set<BriefFilterId>();
95
+ for (const word of words(query)) {
96
+ const id = filterOfWord(word);
97
+ if (id) ticked.add(id);
98
+ }
99
+ return ticked;
100
+ };
101
+
102
+ /** The category a query is scoped to by its own terms, `"all"` when none is. */
103
+ export const briefQueryCategory = (query: string): BriefCategoryFilter => {
104
+ let category: BriefCategoryFilter = "all";
105
+ for (const word of words(query)) {
106
+ const named = categoryOfWord(word);
107
+ if (named) category = named;
108
+ }
109
+ return category;
110
+ };
111
+
112
+ /**
113
+ * The query with a chip's term written in or taken out, or `undefined` when the
114
+ * chip has no term — the caller narrows the same rows from the panel's own
115
+ * state instead, rather than pressing a control that writes nothing.
116
+ */
117
+ export const toggleBriefFilterInQuery = (
118
+ query: string,
119
+ id: BriefFilterId,
120
+ ): string | undefined => {
121
+ const term = termFor(id);
122
+ if (!term) return undefined;
123
+ const typed = words(query);
124
+ const kept = typed.filter((word) => filterOfWord(word) !== id);
125
+ if (kept.length < typed.length) return rejoin(kept);
126
+ return rejoin([...typed, searchTokenTerm(term.name, term.value)]);
127
+ };
128
+
129
+ /** The query scoped to one category, or with its category term taken out for `"all"`. */
130
+ export const setBriefCategoryInQuery = (
131
+ query: string,
132
+ category: BriefCategoryFilter,
133
+ ): string => {
134
+ const kept = words(query).filter((word) => !categoryOfWord(word));
135
+ if (category === "all") return rejoin(kept);
136
+ return rejoin([...kept, searchTokenTerm(CATEGORY_TERM_NAME, category)]);
137
+ };
138
+
139
+ /** The query with every chip term taken out, leaving what was typed. */
140
+ export const clearBriefFiltersInQuery = (query: string): string =>
141
+ rejoin(
142
+ words(query).filter((word) => !filterOfWord(word) && !categoryOfWord(word)),
143
+ );
144
+
145
+ /** Whether a query is narrowing the list — the state in which chips are terms. */
146
+ export const briefQueryIsActive = (query: string): boolean =>
147
+ query.trim().length > 0;
148
+
149
+ /**
150
+ * The chips shown ticked. Under a query the terms are the whole answer for
151
+ * every chip that has one; the two that have none keep answering to the panel's
152
+ * own state, so a search never silently stops them narrowing.
153
+ */
154
+ export const briefChipFilters = (input: {
155
+ query: string;
156
+ ownFilters: ReadonlySet<BriefFilterId>;
157
+ }): ReadonlySet<BriefFilterId> => {
158
+ if (!briefQueryIsActive(input.query)) return input.ownFilters;
159
+ const ticked = new Set(briefQueryFilters(input.query));
160
+ for (const id of input.ownFilters) {
161
+ if (!briefFilterHasTerm(id)) ticked.add(id);
162
+ }
163
+ return ticked;
164
+ };
165
+
166
+ /** The category scope shown selected — the query's under a query, else the panel's. */
167
+ export const briefChipCategory = (input: {
168
+ query: string;
169
+ ownCategory: BriefCategoryFilter;
170
+ }): BriefCategoryFilter =>
171
+ briefQueryIsActive(input.query)
172
+ ? briefQueryCategory(input.query)
173
+ : input.ownCategory;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * How a search query is cut into terms.
3
+ *
4
+ * One implementation, because two would disagree: the token parser
5
+ * (`web-client/src/lib/search-tokens.ts`) reads `in:"Sent Items"` as one term
6
+ * and the brief's chips write and remove terms of the same query
7
+ * (`brief-filter-query.ts`). A splitter that broke on whitespace alone would
8
+ * read `is:unread` inside a quoted value as a term of its own, and editing it
9
+ * would reach inside what the user typed between quotes.
10
+ *
11
+ * A value carrying whitespace is written in double quotes; an unterminated
12
+ * quote runs to the end of the input, so a value stays one term while it is
13
+ * still being typed.
14
+ */
15
+
16
+ /** One whitespace-separated term of a query, with where it sits in the input. */
17
+ export interface SearchQueryWord {
18
+ /** The term exactly as typed, quotes included. */
19
+ raw: string;
20
+ /** Index of the term's first character in the query. */
21
+ start: number;
22
+ /** Index just past the term's last character. */
23
+ end: number;
24
+ }
25
+
26
+ /** Split a query into terms on whitespace, except inside double quotes. */
27
+ export function splitSearchWords(query: string): SearchQueryWord[] {
28
+ const words: SearchQueryWord[] = [];
29
+ let start = -1;
30
+ let quoted = false;
31
+ for (let i = 0; i < query.length; i++) {
32
+ const char = query[i] as string;
33
+ if (char === '"') {
34
+ quoted = !quoted;
35
+ if (start < 0) start = i;
36
+ continue;
37
+ }
38
+ if (!quoted && /\s/.test(char)) {
39
+ if (start >= 0) words.push({ raw: query.slice(start, i), start, end: i });
40
+ start = -1;
41
+ continue;
42
+ }
43
+ if (start < 0) start = i;
44
+ }
45
+ if (start >= 0) {
46
+ words.push({ raw: query.slice(start), start, end: query.length });
47
+ }
48
+ return words;
49
+ }
50
+
51
+ /** A term split at its first colon: the token name and the value as typed. */
52
+ export interface SearchTermParts {
53
+ name: string;
54
+ /** The value with its quotes removed. */
55
+ value: string;
56
+ /** The value exactly as typed, quotes included. */
57
+ rawValue: string;
58
+ }
59
+
60
+ const unquote = (value: string): string => {
61
+ if (!value.startsWith('"')) return value;
62
+ const inner = value.slice(1);
63
+ return inner.endsWith('"') ? inner.slice(0, -1) : inner;
64
+ };
65
+
66
+ /**
67
+ * Split `name:value` at the first colon. A term with no colon, or one starting
68
+ * with a colon, is not a token attempt and returns `undefined`.
69
+ */
70
+ export function splitSearchTerm(word: string): SearchTermParts | undefined {
71
+ const colon = word.indexOf(":");
72
+ if (colon <= 0) return undefined;
73
+ const rawValue = word.slice(colon + 1);
74
+ return {
75
+ name: word.slice(0, colon).toLowerCase(),
76
+ value: unquote(rawValue),
77
+ rawValue,
78
+ };
79
+ }
80
+
81
+ const needsQuotes = (value: string): boolean => /[\s"]/.test(value);
82
+
83
+ /**
84
+ * A value as it is written in a query: quoted when it carries whitespace, bare
85
+ * otherwise. The inverse of the unquoting above, so a suggestion the user picks
86
+ * parses back to the value it was built from.
87
+ */
88
+ export const quoteSearchTokenValue = (value: string): string =>
89
+ needsQuotes(value) ? `"${value.replace(/"/g, "")}"` : value;
90
+
91
+ /** `name:value`, quoted as needed — the text a query carries for one token. */
92
+ export const searchTokenTerm = (name: string, value: string): string =>
93
+ `${name}:${quoteSearchTokenValue(value)}`;