@md-plugins/search-ui 0.1.0-rc.9 → 1.0.0

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/dist/index.mjs CHANGED
@@ -13,187 +13,147 @@ const markdownListMarkerRE = /^\s*(?:[-*+]|\d+\.)\s+/gm;
13
13
  const markdownReferenceDefinitionRE = /^\[[^\]]+\]:\s+\S+.*$/gm;
14
14
  const spaceBeforePunctuationRE = /\s+([,.;:!?])/g;
15
15
  function stripMarkdownSyntax(value) {
16
- return value.replace(markdownReferenceDefinitionRE, " ").replace(markdownImageRE, "$1").replace(markdownLinkRE, "$1").replace(markdownReferenceLinkRE, "$1").replace(markdownInlineCodeRE, "$1").replace(markdownStrongRE, "$2").replace(markdownEmphasisRE, "$2").replace(markdownStrikeRE, "$1").replace(markdownHeadingMarkerRE, " ").replace(markdownBlockquoteMarkerRE, " ").replace(markdownListMarkerRE, " ").replace(htmlTagRE, " ").replaceAll("|", " ").replace(spaceBeforePunctuationRE, "$1");
16
+ return value.replace(markdownReferenceDefinitionRE, " ").replace(markdownImageRE, "$1").replace(markdownLinkRE, "$1").replace(markdownReferenceLinkRE, "$1").replace(markdownInlineCodeRE, "$1").replace(markdownStrongRE, "$2").replace(markdownEmphasisRE, "$2").replace(markdownStrikeRE, "$1").replace(markdownHeadingMarkerRE, " ").replace(markdownBlockquoteMarkerRE, " ").replace(markdownListMarkerRE, " ").replace(htmlTagRE, " ").replaceAll("|", " ").replace(spaceBeforePunctuationRE, "$1");
17
17
  }
18
18
  function normalizeSearchQuery(value) {
19
- return value.toLocaleLowerCase().replace(whitespaceRE, " ").trim();
19
+ return value.toLocaleLowerCase().replace(whitespaceRE, " ").trim();
20
20
  }
21
21
  function createSearchTerms(query) {
22
- return Array.from(new Set(normalizeSearchQuery(query).split(" ").filter(Boolean)));
22
+ return Array.from(new Set(normalizeSearchQuery(query).split(" ").filter(Boolean)));
23
23
  }
24
24
  function normalizeSearchableText(value) {
25
- return stripMarkdownSyntax(String(value ?? "")).toLocaleLowerCase().replace(whitespaceRE, " ").trim();
25
+ return stripMarkdownSyntax(String(value ?? "")).toLocaleLowerCase().replace(whitespaceRE, " ").trim();
26
26
  }
27
27
  function createSearchSnippet(content, terms, length = 140) {
28
- const normalizedContent = stripMarkdownSyntax(content).replace(whitespaceRE, " ").trim();
29
- if (normalizedContent.length <= length) {
30
- return normalizedContent;
31
- }
32
- const lowerContent = normalizedContent.toLocaleLowerCase();
33
- const firstMatch = terms.map((term) => lowerContent.indexOf(term)).filter((index) => index >= 0).sort((a, b) => a - b)[0];
34
- if (firstMatch === void 0) {
35
- return `${normalizedContent.slice(0, length - 1).trim()}\u2026`;
36
- }
37
- const start = Math.max(firstMatch - Math.floor(length / 3), 0);
38
- const end = Math.min(start + length, normalizedContent.length);
39
- const prefix = start > 0 ? "\u2026" : "";
40
- const suffix = end < normalizedContent.length ? "\u2026" : "";
41
- return `${prefix}${normalizedContent.slice(start, end).trim()}${suffix}`;
28
+ const normalizedContent = stripMarkdownSyntax(content).replace(whitespaceRE, " ").trim();
29
+ if (normalizedContent.length <= length) return normalizedContent;
30
+ const lowerContent = normalizedContent.toLocaleLowerCase();
31
+ const firstMatch = terms.map((term) => lowerContent.indexOf(term)).filter((index) => index >= 0).sort((a, b) => a - b)[0];
32
+ if (firstMatch === void 0) return `${normalizedContent.slice(0, length - 1).trim()}…`;
33
+ const start = Math.max(firstMatch - Math.floor(length / 3), 0);
34
+ const end = Math.min(start + length, normalizedContent.length);
35
+ const prefix = start > 0 ? "…" : "";
36
+ const suffix = end < normalizedContent.length ? "…" : "";
37
+ return `${prefix}${normalizedContent.slice(start, end).trim()}${suffix}`;
42
38
  }
43
39
 
44
40
  function normalizeIndexPayload(payload) {
45
- return Array.isArray(payload) ? payload : payload.records;
41
+ return Array.isArray(payload) ? payload : payload.records;
46
42
  }
47
43
  function getSearchFields(record) {
48
- return {
49
- title: normalizeSearchableText(record.title),
50
- section: normalizeSearchableText(record.section),
51
- hierarchy: normalizeSearchableText(record.hierarchy.join(" ")),
52
- tags: normalizeSearchableText(record.tags.join(" ")),
53
- content: normalizeSearchableText(record.content)
54
- };
44
+ return {
45
+ title: normalizeSearchableText(record.title),
46
+ section: normalizeSearchableText(record.section),
47
+ hierarchy: normalizeSearchableText(record.hierarchy.join(" ")),
48
+ tags: normalizeSearchableText(record.tags.join(" ")),
49
+ content: normalizeSearchableText(record.content)
50
+ };
55
51
  }
56
52
  function scoreRecord(record, terms) {
57
- const fields = getSearchFields(record);
58
- let score = 0;
59
- for (const term of terms) {
60
- let matched = false;
61
- if (fields.title.includes(term)) {
62
- score += fields.title.startsWith(term) ? 36 : 28;
63
- matched = true;
64
- }
65
- if (fields.section.includes(term)) {
66
- score += fields.section.startsWith(term) ? 24 : 18;
67
- matched = true;
68
- }
69
- if (fields.hierarchy.includes(term)) {
70
- score += 12;
71
- matched = true;
72
- }
73
- if (fields.tags.includes(term)) {
74
- score += 10;
75
- matched = true;
76
- }
77
- if (fields.content.includes(term)) {
78
- score += record.type === "content" ? 8 : 5;
79
- matched = true;
80
- }
81
- if (!matched) {
82
- return 0;
83
- }
84
- }
85
- if (record.type === "page") {
86
- score += 6;
87
- } else if (record.type === "heading") {
88
- score += 4;
89
- }
90
- return score;
53
+ const fields = getSearchFields(record);
54
+ let score = 0;
55
+ for (const term of terms) {
56
+ let matched = false;
57
+ if (fields.title.includes(term)) {
58
+ score += fields.title.startsWith(term) ? 36 : 28;
59
+ matched = true;
60
+ }
61
+ if (fields.section.includes(term)) {
62
+ score += fields.section.startsWith(term) ? 24 : 18;
63
+ matched = true;
64
+ }
65
+ if (fields.hierarchy.includes(term)) {
66
+ score += 12;
67
+ matched = true;
68
+ }
69
+ if (fields.tags.includes(term)) {
70
+ score += 10;
71
+ matched = true;
72
+ }
73
+ if (fields.content.includes(term)) {
74
+ score += record.type === "content" ? 8 : 5;
75
+ matched = true;
76
+ }
77
+ if (!matched) return 0;
78
+ }
79
+ if (record.type === "page") score += 6;
80
+ else if (record.type === "heading") score += 4;
81
+ return score;
91
82
  }
92
83
  function hasUsefulContent(result) {
93
- const content = normalizeSearchableText(result.content);
94
- if (content === "") {
95
- return false;
96
- }
97
- return content !== normalizeSearchableText(result.title) && content !== normalizeSearchableText(result.section);
84
+ const content = normalizeSearchableText(result.content);
85
+ if (content === "") return false;
86
+ return content !== normalizeSearchableText(result.title) && content !== normalizeSearchableText(result.section);
98
87
  }
99
88
  function preferDuplicateResult(current, candidate) {
100
- if (current.type === "content" && candidate.type !== "content" && hasUsefulContent(current)) {
101
- return current;
102
- }
103
- if (candidate.type === "content" && current.type !== "content" && hasUsefulContent(candidate)) {
104
- return candidate;
105
- }
106
- if (candidate.score > current.score) {
107
- return candidate;
108
- }
109
- return current;
89
+ if (current.type === "content" && candidate.type !== "content" && hasUsefulContent(current)) return current;
90
+ if (candidate.type === "content" && current.type !== "content" && hasUsefulContent(candidate)) return candidate;
91
+ if (candidate.score > current.score) return candidate;
92
+ return current;
110
93
  }
111
94
  function collapseDuplicateResults(results) {
112
- const byUrl = /* @__PURE__ */ new Map();
113
- for (const result of results) {
114
- const current = byUrl.get(result.url);
115
- byUrl.set(result.url, current === void 0 ? result : preferDuplicateResult(current, result));
116
- }
117
- return Array.from(byUrl.values()).sort(
118
- (a, b) => b.score - a.score || a.title.localeCompare(b.title)
119
- );
95
+ const byUrl = /* @__PURE__ */ new Map();
96
+ for (const result of results) {
97
+ const current = byUrl.get(result.url);
98
+ byUrl.set(result.url, current === void 0 ? result : preferDuplicateResult(current, result));
99
+ }
100
+ return Array.from(byUrl.values()).sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
120
101
  }
121
102
  function searchRecords(records, query, options = {}) {
122
- const terms = createSearchTerms(query);
123
- if (terms.length === 0) {
124
- return [];
125
- }
126
- const results = records.map((record) => {
127
- const score = scoreRecord(record, terms);
128
- if (score === 0) {
129
- return void 0;
130
- }
131
- return {
132
- id: record.id,
133
- title: record.title,
134
- url: record.url,
135
- content: createSearchSnippet(record.content, terms),
136
- score,
137
- type: record.type,
138
- path: record.path,
139
- section: record.section,
140
- hierarchy: record.hierarchy,
141
- tags: record.tags,
142
- record
143
- };
144
- }).filter((result) => result !== void 0).sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
145
- const displayResults = options.collapseDuplicateResults === false ? results : collapseDuplicateResults(results);
146
- return displayResults.slice(0, options.limit ?? 12);
103
+ const terms = createSearchTerms(query);
104
+ if (terms.length === 0) return [];
105
+ const results = records.map((record) => {
106
+ const score = scoreRecord(record, terms);
107
+ if (score === 0) return;
108
+ return {
109
+ id: record.id,
110
+ title: record.title,
111
+ url: record.url,
112
+ content: createSearchSnippet(record.content, terms),
113
+ score,
114
+ type: record.type,
115
+ path: record.path,
116
+ section: record.section,
117
+ hierarchy: record.hierarchy,
118
+ tags: record.tags,
119
+ record
120
+ };
121
+ }).filter((result) => result !== void 0).sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
122
+ return (options.collapseDuplicateResults === false ? results : collapseDuplicateResults(results)).slice(0, options.limit ?? 12);
147
123
  }
148
124
  function createStaticSearchProvider(records) {
149
- return {
150
- search(query, options) {
151
- return searchRecords(records, query, options);
152
- }
153
- };
125
+ return { search(query, options) {
126
+ return searchRecords(records, query, options);
127
+ } };
154
128
  }
155
129
  function createJsonSearchProvider(options = {}) {
156
- let recordsPromise;
157
- async function loadRecords() {
158
- if (options.index !== void 0) {
159
- return normalizeIndexPayload(options.index);
160
- }
161
- if (options.src === void 0) {
162
- throw new Error('createJsonSearchProvider requires either "src" or "index".');
163
- }
164
- const src = options.src;
165
- recordsPromise ??= Promise.resolve().then(async () => {
166
- const fetcher = options.fetcher ?? globalThis.fetch;
167
- if (fetcher === void 0) {
168
- throw new Error("No fetch implementation is available for loading the search index.");
169
- }
170
- const response = await fetcher(src);
171
- if (!response.ok) {
172
- throw new Error(`Unable to load search index: ${response.status} ${response.statusText}`);
173
- }
174
- const responseText = await response.text();
175
- try {
176
- return normalizeIndexPayload(JSON.parse(responseText));
177
- } catch {
178
- const contentType = response.headers.get("content-type") ?? "unknown content type";
179
- const preview = responseText.trim().slice(0, 120);
180
- throw new Error(
181
- `Unable to parse search index JSON from ${src}. Received ${contentType}${preview ? `: ${preview}` : "."}`
182
- );
183
- }
184
- });
185
- return recordsPromise;
186
- }
187
- return {
188
- async search(query, searchOptions) {
189
- return searchRecords(await loadRecords(), query, searchOptions);
190
- }
191
- };
130
+ let recordsPromise;
131
+ async function loadRecords() {
132
+ if (options.index !== void 0) return normalizeIndexPayload(options.index);
133
+ if (options.src === void 0) throw new Error("createJsonSearchProvider requires either \"src\" or \"index\".");
134
+ const src = options.src;
135
+ recordsPromise ??= Promise.resolve().then(async () => {
136
+ const fetcher = options.fetcher ?? globalThis.fetch;
137
+ if (fetcher === void 0) throw new Error("No fetch implementation is available for loading the search index.");
138
+ const response = await fetcher(src);
139
+ if (!response.ok) throw new Error(`Unable to load search index: ${response.status} ${response.statusText}`);
140
+ const responseText = await response.text();
141
+ try {
142
+ return normalizeIndexPayload(JSON.parse(responseText));
143
+ } catch {
144
+ const contentType = response.headers.get("content-type") ?? "unknown content type";
145
+ const preview = responseText.trim().slice(0, 120);
146
+ throw new Error(`Unable to parse search index JSON from ${src}. Received ${contentType}${preview ? `: ${preview}` : "."}`);
147
+ }
148
+ });
149
+ return recordsPromise;
150
+ }
151
+ return { async search(query, searchOptions) {
152
+ return searchRecords(await loadRecords(), query, searchOptions);
153
+ } };
192
154
  }
193
155
 
194
- const templateStyles = (
195
- /* css */
196
- `
156
+ const templateStyles = `
197
157
  :host {
198
158
  color-scheme: var(--md-search-color-scheme, light);
199
159
  --md-search-z-index: 5000;
@@ -530,342 +490,283 @@ const templateStyles = (
530
490
  display: none;
531
491
  }
532
492
  }
533
- `
534
- );
535
- const icon = "\u2315";
493
+ `;
494
+ const icon = "⌕";
536
495
  function getShortcutLabel(shortcut) {
537
- if (shortcut === "none") {
538
- return "";
539
- }
540
- if (shortcut === "mod+k") {
541
- const isMac = /Mac|iPhone|iPad|iPod/i.test(globalThis.navigator?.platform ?? "");
542
- return isMac ? "\u2318K" : "Ctrl K";
543
- }
544
- return shortcut;
496
+ if (shortcut === "none") return "";
497
+ if (shortcut === "mod+k") return /Mac|iPhone|iPad|iPod/i.test(globalThis.navigator?.platform ?? "") ? "⌘K" : "Ctrl K";
498
+ return shortcut;
545
499
  }
546
500
  function escapeHtml(value) {
547
- return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
501
+ return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
548
502
  }
549
503
  function escapeRegExp(value) {
550
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
504
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
551
505
  }
552
506
  function renderHighlightedText(value, terms) {
553
- const text = String(value ?? "");
554
- if (text === "" || terms.length === 0) {
555
- return escapeHtml(text);
556
- }
557
- const pattern = new RegExp(
558
- terms.filter(Boolean).sort((a, b) => b.length - a.length).map(escapeRegExp).join("|"),
559
- "gi"
560
- );
561
- let output = "";
562
- let lastIndex = 0;
563
- for (const match of text.matchAll(pattern)) {
564
- const index = match.index ?? 0;
565
- output += escapeHtml(text.slice(lastIndex, index));
566
- output += `<mark>${escapeHtml(match[0])}</mark>`;
567
- lastIndex = index + match[0].length;
568
- }
569
- output += escapeHtml(text.slice(lastIndex));
570
- return output;
507
+ const text = String(value ?? "");
508
+ if (text === "" || terms.length === 0) return escapeHtml(text);
509
+ const pattern = new RegExp(terms.filter(Boolean).sort((a, b) => b.length - a.length).map(escapeRegExp).join("|"), "gi");
510
+ let output = "";
511
+ let lastIndex = 0;
512
+ for (const match of text.matchAll(pattern)) {
513
+ const index = match.index ?? 0;
514
+ output += escapeHtml(text.slice(lastIndex, index));
515
+ output += `<mark>${escapeHtml(match[0])}</mark>`;
516
+ lastIndex = index + match[0].length;
517
+ }
518
+ output += escapeHtml(text.slice(lastIndex));
519
+ return output;
571
520
  }
572
521
  function formatResultType(type) {
573
- if (type === "page") {
574
- return "page";
575
- }
576
- if (type === "heading") {
577
- return "heading";
578
- }
579
- return "content";
522
+ if (type === "page") return "page";
523
+ if (type === "heading") return "heading";
524
+ return "content";
580
525
  }
581
526
  function getResultTrail(result) {
582
- const trail = (result.hierarchy.length > 0 ? result.hierarchy : [result.title]).filter(
583
- (entry, index, entries) => entry !== "" && entry !== entries[index - 1]
584
- );
585
- if (result.section !== void 0 && !trail.includes(result.section)) {
586
- return [...trail, result.section];
587
- }
588
- return trail;
527
+ const trail = (result.hierarchy.length > 0 ? result.hierarchy : [result.title]).filter((entry, index, entries) => entry !== "" && entry !== entries[index - 1]);
528
+ if (result.section !== void 0 && !trail.includes(result.section)) return [...trail, result.section];
529
+ return trail;
589
530
  }
590
531
  function renderResultTrail(result, terms) {
591
- return getResultTrail(result).filter(Boolean).map((entry) => `<span>${renderHighlightedText(entry, terms)}</span>`).join('<span class="result__separator" aria-hidden="true">&rsaquo;</span>');
532
+ return getResultTrail(result).filter(Boolean).map((entry) => `<span>${renderHighlightedText(entry, terms)}</span>`).join("<span class=\"result__separator\" aria-hidden=\"true\">&rsaquo;</span>");
592
533
  }
593
- const BrowserHTMLElement = globalThis.HTMLElement;
594
- const HTMLElementBase = BrowserHTMLElement ?? class MdSearchServerElement {
595
- };
596
- class MdSearchElement extends HTMLElementBase {
597
- static observedAttributes = [
598
- "src",
599
- "placeholder",
600
- "trigger-label",
601
- "panel-title",
602
- "shortcut",
603
- "theme",
604
- "search-label",
605
- "min-query-length",
606
- "max-results",
607
- "show-duplicate-results"
608
- ];
609
- provider;
610
- root = this.attachShadow({ mode: "open" });
611
- elementId = `md-search-${Math.random().toString(36).slice(2)}`;
612
- results = [];
613
- query = "";
614
- opened = false;
615
- loading = false;
616
- errorMessage = "";
617
- activeIndex = 0;
618
- searchTimer;
619
- searchRequestId = 0;
620
- selectionStart = null;
621
- selectionEnd = null;
622
- connectedCallback() {
623
- this.render();
624
- globalThis.addEventListener?.("keydown", this.onGlobalKeydown);
625
- }
626
- disconnectedCallback() {
627
- globalThis.removeEventListener?.("keydown", this.onGlobalKeydown);
628
- clearTimeout(this.searchTimer);
629
- }
630
- attributeChangedCallback(name) {
631
- if (name === "src") {
632
- this.provider = void 0;
633
- }
634
- if (name === "show-duplicate-results" && this.query.trim().length >= this.minQueryLength) {
635
- void this.runSearch();
636
- return;
637
- }
638
- this.render();
639
- }
640
- open() {
641
- this.opened = true;
642
- this.render();
643
- this.focusInput();
644
- }
645
- close() {
646
- this.opened = false;
647
- this.render();
648
- }
649
- toggle() {
650
- if (this.opened) {
651
- this.close();
652
- } else {
653
- this.open();
654
- }
655
- }
656
- get src() {
657
- return this.getAttribute("src") ?? void 0;
658
- }
659
- get placeholder() {
660
- return this.getAttribute("placeholder") ?? "Search docs...";
661
- }
662
- get triggerLabel() {
663
- return this.getAttribute("trigger-label") ?? "Search";
664
- }
665
- get panelTitle() {
666
- return this.getAttribute("panel-title") ?? "Search";
667
- }
668
- get searchLabel() {
669
- return this.getAttribute("search-label") ?? this.panelTitle;
670
- }
671
- get shortcut() {
672
- return this.getAttribute("shortcut") ?? "mod+k";
673
- }
674
- get minQueryLength() {
675
- return Number(this.getAttribute("min-query-length") ?? 2);
676
- }
677
- get maxResults() {
678
- return Number(this.getAttribute("max-results") ?? 12);
679
- }
680
- get showDuplicateResults() {
681
- return this.hasAttribute("show-duplicate-results");
682
- }
683
- get activeResult() {
684
- return this.results[this.activeIndex];
685
- }
686
- getProvider() {
687
- this.provider ??= createJsonSearchProvider({ src: this.src });
688
- return this.provider;
689
- }
690
- onGlobalKeydown = (event) => {
691
- if (this.shortcut !== "mod+k") {
692
- return;
693
- }
694
- if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === "k") {
695
- event.preventDefault();
696
- this.open();
697
- }
698
- };
699
- onInput = (event) => {
700
- const input = event.target;
701
- this.query = input.value;
702
- this.selectionStart = input.selectionStart;
703
- this.selectionEnd = input.selectionEnd;
704
- this.searchRequestId++;
705
- clearTimeout(this.searchTimer);
706
- if (this.query.trim().length < this.minQueryLength) {
707
- this.results = [];
708
- this.loading = false;
709
- this.errorMessage = "";
710
- this.activeIndex = 0;
711
- this.renderResultsContainer();
712
- return;
713
- }
714
- this.loading = true;
715
- this.errorMessage = "";
716
- this.renderResultsContainer();
717
- this.searchTimer = setTimeout(() => {
718
- void this.runSearch();
719
- }, 120);
720
- };
721
- onDialogKeydown = (event) => {
722
- if (event.key === "Escape") {
723
- event.preventDefault();
724
- this.close();
725
- return;
726
- }
727
- if (event.key === "ArrowDown") {
728
- event.preventDefault();
729
- this.setActiveIndex(this.activeIndex + 1, {
730
- scrollIntoView: true
731
- });
732
- return;
733
- }
734
- if (event.key === "ArrowUp") {
735
- event.preventDefault();
736
- this.setActiveIndex(this.activeIndex - 1, {
737
- scrollIntoView: true
738
- });
739
- return;
740
- }
741
- if (event.key === "Enter" && this.activeResult !== void 0) {
742
- event.preventDefault();
743
- this.selectResult(this.activeResult);
744
- }
745
- };
746
- async runSearch() {
747
- const requestId = ++this.searchRequestId;
748
- try {
749
- const results = await this.getProvider().search(this.query, {
750
- limit: this.maxResults,
751
- collapseDuplicateResults: !this.showDuplicateResults
752
- });
753
- if (requestId !== this.searchRequestId) {
754
- return;
755
- }
756
- this.results = results;
757
- this.loading = false;
758
- this.errorMessage = "";
759
- this.activeIndex = 0;
760
- } catch (error) {
761
- if (requestId !== this.searchRequestId) {
762
- return;
763
- }
764
- this.results = [];
765
- this.loading = false;
766
- this.errorMessage = error instanceof Error ? error.message : "Search failed.";
767
- }
768
- this.renderResultsContainer();
769
- }
770
- focusInput(options = {}) {
771
- const { selectionStart, selectionEnd } = this;
772
- requestAnimationFrame(() => {
773
- const input = this.root.querySelector('[part="input"]');
774
- input?.focus();
775
- if (input !== null && options.restoreSelection === true && selectionStart !== null && selectionEnd !== null) {
776
- input.setSelectionRange(selectionStart, selectionEnd);
777
- }
778
- });
779
- }
780
- setActiveIndex(index, options = {}) {
781
- if (this.results.length === 0) {
782
- return;
783
- }
784
- this.activeIndex = Math.min(Math.max(index, 0), this.results.length - 1);
785
- this.syncActiveResult(options);
786
- }
787
- syncInputAria() {
788
- const input = this.root.querySelector('[part="input"]');
789
- if (input === null) {
790
- return;
791
- }
792
- input.setAttribute("aria-expanded", this.results.length > 0 ? "true" : "false");
793
- if (this.results.length === 0) {
794
- input.removeAttribute("aria-activedescendant");
795
- return;
796
- }
797
- input.setAttribute("aria-activedescendant", `${this.elementId}-result-${this.activeIndex}`);
798
- }
799
- syncActiveResult(options = {}) {
800
- this.syncInputAria();
801
- this.root.querySelectorAll("[data-result-index]").forEach((element) => {
802
- const isActive = Number(element.dataset.resultIndex ?? -1) === this.activeIndex;
803
- element.classList.toggle("result--active", isActive);
804
- element.setAttribute("aria-selected", isActive ? "true" : "false");
805
- if (isActive && options.scrollIntoView === true) {
806
- element.scrollIntoView({
807
- block: "nearest"
808
- });
809
- }
810
- });
811
- }
812
- selectResult(result) {
813
- const event = new CustomEvent("md-search-select", {
814
- bubbles: true,
815
- cancelable: true,
816
- composed: true,
817
- detail: { result }
818
- });
819
- const shouldNavigate = this.dispatchEvent(event);
820
- this.close();
821
- if (shouldNavigate) {
822
- globalThis.location.assign(result.url);
823
- }
824
- }
825
- bindResultEvents() {
826
- this.root.querySelectorAll("[data-result-index]").forEach((element) => {
827
- element.addEventListener("mouseenter", () => {
828
- this.setActiveIndex(Number(element.dataset.resultIndex ?? 0));
829
- });
830
- element.addEventListener("click", () => {
831
- const result = this.results[Number(element.dataset.resultIndex ?? 0)];
832
- if (result !== void 0) {
833
- this.selectResult(result);
834
- }
835
- });
836
- });
837
- }
838
- renderResultsContainer() {
839
- const container = this.root.querySelector('[part="results"]');
840
- if (container === null) {
841
- this.render();
842
- return;
843
- }
844
- container.innerHTML = this.renderResults();
845
- this.bindResultEvents();
846
- this.syncInputAria();
847
- }
848
- renderResults() {
849
- if (this.loading) {
850
- return '<div part="status" class="status" role="status" aria-live="polite">Searching...</div>';
851
- }
852
- if (this.errorMessage !== "") {
853
- return `<div part="status" class="status" role="status" aria-live="assertive">${escapeHtml(this.errorMessage)}</div>`;
854
- }
855
- if (this.query.trim().length < this.minQueryLength) {
856
- return `<div part="status" class="status" role="status" aria-live="polite">Type at least ${this.minQueryLength} characters.</div>`;
857
- }
858
- if (this.results.length === 0) {
859
- return '<div part="status" class="status" role="status" aria-live="polite">No results found.</div>';
860
- }
861
- const terms = createSearchTerms(this.query);
862
- return `
534
+ const HTMLElementBase = globalThis.HTMLElement ?? class MdSearchServerElement {};
535
+ var MdSearchElement = class extends HTMLElementBase {
536
+ static observedAttributes = [
537
+ "src",
538
+ "placeholder",
539
+ "trigger-label",
540
+ "panel-title",
541
+ "shortcut",
542
+ "theme",
543
+ "search-label",
544
+ "min-query-length",
545
+ "max-results",
546
+ "show-duplicate-results"
547
+ ];
548
+ provider;
549
+ root = this.attachShadow({ mode: "open" });
550
+ elementId = `md-search-${Math.random().toString(36).slice(2)}`;
551
+ results = [];
552
+ query = "";
553
+ opened = false;
554
+ loading = false;
555
+ errorMessage = "";
556
+ activeIndex = 0;
557
+ searchTimer;
558
+ searchRequestId = 0;
559
+ selectionStart = null;
560
+ selectionEnd = null;
561
+ connectedCallback() {
562
+ this.render();
563
+ globalThis.addEventListener?.("keydown", this.onGlobalKeydown);
564
+ }
565
+ disconnectedCallback() {
566
+ globalThis.removeEventListener?.("keydown", this.onGlobalKeydown);
567
+ clearTimeout(this.searchTimer);
568
+ }
569
+ attributeChangedCallback(name) {
570
+ if (name === "src") this.provider = void 0;
571
+ if (name === "show-duplicate-results" && this.query.trim().length >= this.minQueryLength) {
572
+ this.runSearch();
573
+ return;
574
+ }
575
+ this.render();
576
+ }
577
+ open() {
578
+ this.opened = true;
579
+ this.render();
580
+ this.focusInput();
581
+ }
582
+ close() {
583
+ this.opened = false;
584
+ this.render();
585
+ }
586
+ toggle() {
587
+ if (this.opened) this.close();
588
+ else this.open();
589
+ }
590
+ get src() {
591
+ return this.getAttribute("src") ?? void 0;
592
+ }
593
+ get placeholder() {
594
+ return this.getAttribute("placeholder") ?? "Search docs...";
595
+ }
596
+ get triggerLabel() {
597
+ return this.getAttribute("trigger-label") ?? "Search";
598
+ }
599
+ get panelTitle() {
600
+ return this.getAttribute("panel-title") ?? "Search";
601
+ }
602
+ get searchLabel() {
603
+ return this.getAttribute("search-label") ?? this.panelTitle;
604
+ }
605
+ get shortcut() {
606
+ return this.getAttribute("shortcut") ?? "mod+k";
607
+ }
608
+ get minQueryLength() {
609
+ return Number(this.getAttribute("min-query-length") ?? 2);
610
+ }
611
+ get maxResults() {
612
+ return Number(this.getAttribute("max-results") ?? 12);
613
+ }
614
+ get showDuplicateResults() {
615
+ return this.hasAttribute("show-duplicate-results");
616
+ }
617
+ get activeResult() {
618
+ return this.results[this.activeIndex];
619
+ }
620
+ getProvider() {
621
+ this.provider ??= createJsonSearchProvider({ src: this.src });
622
+ return this.provider;
623
+ }
624
+ onGlobalKeydown = (event) => {
625
+ if (this.shortcut !== "mod+k") return;
626
+ if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === "k") {
627
+ event.preventDefault();
628
+ this.open();
629
+ }
630
+ };
631
+ onInput = (event) => {
632
+ const input = event.target;
633
+ this.query = input.value;
634
+ this.selectionStart = input.selectionStart;
635
+ this.selectionEnd = input.selectionEnd;
636
+ this.searchRequestId++;
637
+ clearTimeout(this.searchTimer);
638
+ if (this.query.trim().length < this.minQueryLength) {
639
+ this.results = [];
640
+ this.loading = false;
641
+ this.errorMessage = "";
642
+ this.activeIndex = 0;
643
+ this.renderResultsContainer();
644
+ return;
645
+ }
646
+ this.loading = true;
647
+ this.errorMessage = "";
648
+ this.renderResultsContainer();
649
+ this.searchTimer = setTimeout(() => {
650
+ this.runSearch();
651
+ }, 120);
652
+ };
653
+ onDialogKeydown = (event) => {
654
+ if (event.key === "Escape") {
655
+ event.preventDefault();
656
+ this.close();
657
+ return;
658
+ }
659
+ if (event.key === "ArrowDown") {
660
+ event.preventDefault();
661
+ this.setActiveIndex(this.activeIndex + 1, { scrollIntoView: true });
662
+ return;
663
+ }
664
+ if (event.key === "ArrowUp") {
665
+ event.preventDefault();
666
+ this.setActiveIndex(this.activeIndex - 1, { scrollIntoView: true });
667
+ return;
668
+ }
669
+ if (event.key === "Enter" && this.activeResult !== void 0) {
670
+ event.preventDefault();
671
+ this.selectResult(this.activeResult);
672
+ }
673
+ };
674
+ async runSearch() {
675
+ const requestId = ++this.searchRequestId;
676
+ try {
677
+ const results = await this.getProvider().search(this.query, {
678
+ limit: this.maxResults,
679
+ collapseDuplicateResults: !this.showDuplicateResults
680
+ });
681
+ if (requestId !== this.searchRequestId) return;
682
+ this.results = results;
683
+ this.loading = false;
684
+ this.errorMessage = "";
685
+ this.activeIndex = 0;
686
+ } catch (error) {
687
+ if (requestId !== this.searchRequestId) return;
688
+ this.results = [];
689
+ this.loading = false;
690
+ this.errorMessage = error instanceof Error ? error.message : "Search failed.";
691
+ }
692
+ this.renderResultsContainer();
693
+ }
694
+ focusInput(options = {}) {
695
+ const { selectionStart, selectionEnd } = this;
696
+ requestAnimationFrame(() => {
697
+ const input = this.root.querySelector("[part=\"input\"]");
698
+ input?.focus();
699
+ if (input !== null && options.restoreSelection === true && selectionStart !== null && selectionEnd !== null) input.setSelectionRange(selectionStart, selectionEnd);
700
+ });
701
+ }
702
+ setActiveIndex(index, options = {}) {
703
+ if (this.results.length === 0) return;
704
+ this.activeIndex = Math.min(Math.max(index, 0), this.results.length - 1);
705
+ this.syncActiveResult(options);
706
+ }
707
+ syncInputAria() {
708
+ const input = this.root.querySelector("[part=\"input\"]");
709
+ if (input === null) return;
710
+ input.setAttribute("aria-expanded", this.results.length > 0 ? "true" : "false");
711
+ if (this.results.length === 0) {
712
+ input.removeAttribute("aria-activedescendant");
713
+ return;
714
+ }
715
+ input.setAttribute("aria-activedescendant", `${this.elementId}-result-${this.activeIndex}`);
716
+ }
717
+ syncActiveResult(options = {}) {
718
+ this.syncInputAria();
719
+ this.root.querySelectorAll("[data-result-index]").forEach((element) => {
720
+ const isActive = Number(element.dataset.resultIndex ?? -1) === this.activeIndex;
721
+ element.classList.toggle("result--active", isActive);
722
+ element.setAttribute("aria-selected", isActive ? "true" : "false");
723
+ if (isActive && options.scrollIntoView === true) element.scrollIntoView({ block: "nearest" });
724
+ });
725
+ }
726
+ selectResult(result) {
727
+ const event = new CustomEvent("md-search-select", {
728
+ bubbles: true,
729
+ cancelable: true,
730
+ composed: true,
731
+ detail: { result }
732
+ });
733
+ const shouldNavigate = this.dispatchEvent(event);
734
+ this.close();
735
+ if (shouldNavigate) globalThis.location.assign(result.url);
736
+ }
737
+ bindResultEvents() {
738
+ this.root.querySelectorAll("[data-result-index]").forEach((element) => {
739
+ element.addEventListener("mouseenter", () => {
740
+ this.setActiveIndex(Number(element.dataset.resultIndex ?? 0));
741
+ });
742
+ element.addEventListener("click", () => {
743
+ const result = this.results[Number(element.dataset.resultIndex ?? 0)];
744
+ if (result !== void 0) this.selectResult(result);
745
+ });
746
+ });
747
+ }
748
+ renderResultsContainer() {
749
+ const container = this.root.querySelector("[part=\"results\"]");
750
+ if (container === null) {
751
+ this.render();
752
+ return;
753
+ }
754
+ container.innerHTML = this.renderResults();
755
+ this.bindResultEvents();
756
+ this.syncInputAria();
757
+ }
758
+ renderResults() {
759
+ if (this.loading) return "<div part=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\">Searching...</div>";
760
+ if (this.errorMessage !== "") return `<div part="status" class="status" role="status" aria-live="assertive">${escapeHtml(this.errorMessage)}</div>`;
761
+ if (this.query.trim().length < this.minQueryLength) return `<div part="status" class="status" role="status" aria-live="polite">Type at least ${this.minQueryLength} characters.</div>`;
762
+ if (this.results.length === 0) return "<div part=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\">No results found.</div>";
763
+ const terms = createSearchTerms(this.query);
764
+ return `
863
765
  <div part="list" class="list" id="${this.elementId}-listbox" role="listbox">
864
766
  ${this.results.map((result, index) => {
865
- const resultId = `${this.elementId}-result-${index}`;
866
- return `
767
+ return `
867
768
  <button
868
- id="${resultId}"
769
+ id="${`${this.elementId}-result-${index}`}"
869
770
  type="button"
870
771
  part="result"
871
772
  class="result ${index === this.activeIndex ? "result--active" : ""}"
@@ -881,16 +782,16 @@ class MdSearchElement extends HTMLElementBase {
881
782
  <div part="result-url" class="result__path">${escapeHtml(result.url)}</div>
882
783
  </button>
883
784
  `;
884
- }).join("")}
785
+ }).join("")}
885
786
  </div>
886
787
  `;
887
- }
888
- render() {
889
- const shortcutLabel = getShortcutLabel(this.shortcut);
890
- const dialogId = `${this.elementId}-dialog`;
891
- const titleId = `${this.elementId}-title`;
892
- const activeResultId = this.results.length > 0 ? `${this.elementId}-result-${this.activeIndex}` : void 0;
893
- this.root.innerHTML = `
788
+ }
789
+ render() {
790
+ const shortcutLabel = getShortcutLabel(this.shortcut);
791
+ const dialogId = `${this.elementId}-dialog`;
792
+ const titleId = `${this.elementId}-title`;
793
+ const activeResultId = this.results.length > 0 ? `${this.elementId}-result-${this.activeIndex}` : void 0;
794
+ this.root.innerHTML = `
894
795
  <style>${templateStyles}</style>
895
796
  <button
896
797
  type="button"
@@ -917,7 +818,7 @@ class MdSearchElement extends HTMLElementBase {
917
818
  <div class="header">
918
819
  <div class="title-row">
919
820
  <h2 id="${titleId}" part="title" class="title">${escapeHtml(this.panelTitle)}</h2>
920
- <button type="button" part="close-button" class="close" aria-label="Close search">\u2715</button>
821
+ <button type="button" part="close-button" class="close" aria-label="Close search">✕</button>
921
822
  </div>
922
823
  <input
923
824
  part="input"
@@ -935,8 +836,8 @@ class MdSearchElement extends HTMLElementBase {
935
836
  spellcheck="false"
936
837
  />
937
838
  <div part="help" class="help" aria-hidden="true">
938
- <span class="help__item">Navigate <kbd class="kbd">\u2193</kbd><kbd class="kbd">\u2191</kbd></span>
939
- <span class="help__item">Select <kbd class="kbd">\u21B5</kbd></span>
839
+ <span class="help__item">Navigate <kbd class="kbd">↓</kbd><kbd class="kbd">↑</kbd></span>
840
+ <span class="help__item">Select <kbd class="kbd">↵</kbd></span>
940
841
  <span class="help__item">Close <kbd class="kbd">Esc</kbd></span>
941
842
  </div>
942
843
  </div>
@@ -946,46 +847,39 @@ class MdSearchElement extends HTMLElementBase {
946
847
  </section>
947
848
  </div>
948
849
  `;
949
- this.bindEvents();
950
- }
951
- bindEvents() {
952
- this.root.querySelector('[part="trigger"]')?.addEventListener("click", () => this.open());
953
- this.root.querySelector('[part="close-button"]')?.addEventListener("click", () => this.close());
954
- this.root.querySelector('[part="backdrop"]')?.addEventListener("click", (event) => {
955
- if (event.target === event.currentTarget) {
956
- this.close();
957
- }
958
- });
959
- this.root.querySelector('[part="dialog"]')?.addEventListener("keydown", this.onDialogKeydown);
960
- this.root.querySelector('[part="input"]')?.addEventListener("input", this.onInput);
961
- this.bindResultEvents();
962
- }
963
- }
850
+ this.bindEvents();
851
+ }
852
+ bindEvents() {
853
+ this.root.querySelector("[part=\"trigger\"]")?.addEventListener("click", () => this.open());
854
+ this.root.querySelector("[part=\"close-button\"]")?.addEventListener("click", () => this.close());
855
+ this.root.querySelector("[part=\"backdrop\"]")?.addEventListener("click", (event) => {
856
+ if (event.target === event.currentTarget) this.close();
857
+ });
858
+ this.root.querySelector("[part=\"dialog\"]")?.addEventListener("keydown", this.onDialogKeydown);
859
+ this.root.querySelector("[part=\"input\"]")?.addEventListener("input", this.onInput);
860
+ this.bindResultEvents();
861
+ }
862
+ };
964
863
  function defineMdSearchElement(name = "md-search") {
965
- if (globalThis.customElements === void 0 || globalThis.customElements.get(name) !== void 0) {
966
- return;
967
- }
968
- globalThis.customElements.define(name, MdSearchElement);
864
+ if (globalThis.customElements === void 0 || globalThis.customElements.get(name) !== void 0) return;
865
+ globalThis.customElements.define(name, MdSearchElement);
969
866
  }
970
867
  function createSearch(options) {
971
- defineMdSearchElement();
972
- const element = document.createElement("md-search");
973
- if (options.src !== void 0) element.setAttribute("src", options.src);
974
- if (options.placeholder !== void 0) element.setAttribute("placeholder", options.placeholder);
975
- if (options.triggerLabel !== void 0)
976
- element.setAttribute("trigger-label", options.triggerLabel);
977
- if (options.panelTitle !== void 0) element.setAttribute("panel-title", options.panelTitle);
978
- if (options.searchLabel !== void 0) element.setAttribute("search-label", options.searchLabel);
979
- if (options.theme !== void 0) element.setAttribute("theme", options.theme);
980
- if (options.shortcut !== void 0) element.setAttribute("shortcut", options.shortcut);
981
- if (options.minQueryLength !== void 0)
982
- element.setAttribute("min-query-length", String(options.minQueryLength));
983
- if (options.maxResults !== void 0)
984
- element.setAttribute("max-results", String(options.maxResults));
985
- if (options.showDuplicateResults === true) element.setAttribute("show-duplicate-results", "");
986
- if (options.provider !== void 0) element.provider = options.provider;
987
- options.target.append(element);
988
- return element;
868
+ defineMdSearchElement();
869
+ const element = document.createElement("md-search");
870
+ if (options.src !== void 0) element.setAttribute("src", options.src);
871
+ if (options.placeholder !== void 0) element.setAttribute("placeholder", options.placeholder);
872
+ if (options.triggerLabel !== void 0) element.setAttribute("trigger-label", options.triggerLabel);
873
+ if (options.panelTitle !== void 0) element.setAttribute("panel-title", options.panelTitle);
874
+ if (options.searchLabel !== void 0) element.setAttribute("search-label", options.searchLabel);
875
+ if (options.theme !== void 0) element.setAttribute("theme", options.theme);
876
+ if (options.shortcut !== void 0) element.setAttribute("shortcut", options.shortcut);
877
+ if (options.minQueryLength !== void 0) element.setAttribute("min-query-length", String(options.minQueryLength));
878
+ if (options.maxResults !== void 0) element.setAttribute("max-results", String(options.maxResults));
879
+ if (options.showDuplicateResults === true) element.setAttribute("show-duplicate-results", "");
880
+ if (options.provider !== void 0) element.provider = options.provider;
881
+ options.target.append(element);
882
+ return element;
989
883
  }
990
884
 
991
- export { MdSearchElement, createJsonSearchProvider, createSearch, createSearchSnippet, createSearchTerms, createStaticSearchProvider, defineMdSearchElement, normalizeSearchQuery, normalizeSearchableText, searchRecords };
885
+ export { MdSearchElement, createJsonSearchProvider, createSearch, createSearchSnippet, createSearchTerms, createStaticSearchProvider, defineMdSearchElement, normalizeSearchQuery, normalizeSearchableText, searchRecords };