@vanduo-oss/framework 1.2.3

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.
Files changed (197) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +216 -0
  3. package/css/components/alerts.css +224 -0
  4. package/css/components/avatar.css +275 -0
  5. package/css/components/badges.css +230 -0
  6. package/css/components/breadcrumbs.css +146 -0
  7. package/css/components/button-group.css +82 -0
  8. package/css/components/buttons.css +530 -0
  9. package/css/components/cards.css +304 -0
  10. package/css/components/chips.css +259 -0
  11. package/css/components/code-snippet.css +555 -0
  12. package/css/components/collapsible.css +267 -0
  13. package/css/components/collections.css +253 -0
  14. package/css/components/doc-search.css +464 -0
  15. package/css/components/doc-tabs.css +38 -0
  16. package/css/components/draggable.css +317 -0
  17. package/css/components/dropdown.css +266 -0
  18. package/css/components/footer.css +375 -0
  19. package/css/components/forms.css +1774 -0
  20. package/css/components/image-box.css +279 -0
  21. package/css/components/modals.css +285 -0
  22. package/css/components/navbar.css +530 -0
  23. package/css/components/pagination.css +186 -0
  24. package/css/components/preloader.css +340 -0
  25. package/css/components/progress.css +107 -0
  26. package/css/components/sidenav.css +301 -0
  27. package/css/components/skeleton.css +241 -0
  28. package/css/components/spinner.css +144 -0
  29. package/css/components/tabs.css +327 -0
  30. package/css/components/theme-customizer.css +835 -0
  31. package/css/components/toast.css +357 -0
  32. package/css/components/tooltips.css +270 -0
  33. package/css/core/colors.css +1017 -0
  34. package/css/core/fonts.css +266 -0
  35. package/css/core/grid.css +1699 -0
  36. package/css/core/helpers.css +2202 -0
  37. package/css/core/reset.css +128 -0
  38. package/css/core/tokens.css +213 -0
  39. package/css/core/typography.css +405 -0
  40. package/css/core/vd-aliases.css +47 -0
  41. package/css/effects/parallax.css +113 -0
  42. package/css/icons/icons-all.css +23 -0
  43. package/css/icons/icons.css +25 -0
  44. package/css/utilities/media.css +167 -0
  45. package/css/utilities/print.css +111 -0
  46. package/css/utilities/shadow.css +243 -0
  47. package/css/utilities/table.css +381 -0
  48. package/css/utilities/transforms.css +71 -0
  49. package/css/utilities/transitions.css +87 -0
  50. package/css/vanduo.css +80 -0
  51. package/dist/build-info.json +6 -0
  52. package/dist/fonts/fira-sans/fira-sans-bold.woff2 +0 -0
  53. package/dist/fonts/fira-sans/fira-sans-medium.woff2 +0 -0
  54. package/dist/fonts/fira-sans/fira-sans-regular.woff2 +0 -0
  55. package/dist/fonts/ibm-plex/ibm-plex-sans-bold.woff2 +0 -0
  56. package/dist/fonts/ibm-plex/ibm-plex-sans-medium.woff2 +0 -0
  57. package/dist/fonts/ibm-plex/ibm-plex-sans-regular.woff2 +0 -0
  58. package/dist/fonts/inter/inter-bold.woff2 +0 -0
  59. package/dist/fonts/inter/inter-medium.woff2 +0 -0
  60. package/dist/fonts/inter/inter-regular.woff2 +0 -0
  61. package/dist/fonts/inter/inter-semibold.woff2 +0 -0
  62. package/dist/fonts/jetbrains-mono/jetbrains-mono-bold.woff2 +0 -0
  63. package/dist/fonts/jetbrains-mono/jetbrains-mono-regular.woff2 +0 -0
  64. package/dist/fonts/open-sans/open-sans-bold.woff2 +0 -0
  65. package/dist/fonts/open-sans/open-sans-medium.woff2 +0 -0
  66. package/dist/fonts/open-sans/open-sans-regular.woff2 +0 -0
  67. package/dist/fonts/rubik/rubik-bold.woff2 +0 -0
  68. package/dist/fonts/rubik/rubik-medium.woff2 +0 -0
  69. package/dist/fonts/rubik/rubik-regular.woff2 +0 -0
  70. package/dist/fonts/source-sans/source-sans-bold.woff2 +0 -0
  71. package/dist/fonts/source-sans/source-sans-regular.woff2 +0 -0
  72. package/dist/fonts/source-sans/source-sans-semibold.woff2 +0 -0
  73. package/dist/fonts/titillium-web/titillium-web-bold.woff2 +0 -0
  74. package/dist/fonts/titillium-web/titillium-web-regular.woff2 +0 -0
  75. package/dist/fonts/titillium-web/titillium-web-semibold.woff2 +0 -0
  76. package/dist/fonts/ubuntu/ubuntu-bold.woff2 +0 -0
  77. package/dist/fonts/ubuntu/ubuntu-medium.woff2 +0 -0
  78. package/dist/fonts/ubuntu/ubuntu-regular.woff2 +0 -0
  79. package/dist/icons/phosphor/LICENSE +21 -0
  80. package/dist/icons/phosphor/bold/Phosphor-Bold.ttf +0 -0
  81. package/dist/icons/phosphor/bold/Phosphor-Bold.woff +0 -0
  82. package/dist/icons/phosphor/bold/Phosphor-Bold.woff2 +0 -0
  83. package/dist/icons/phosphor/bold/style.css +4627 -0
  84. package/dist/icons/phosphor/duotone/Phosphor-Duotone.ttf +0 -0
  85. package/dist/icons/phosphor/duotone/Phosphor-Duotone.woff +0 -0
  86. package/dist/icons/phosphor/duotone/Phosphor-Duotone.woff2 +0 -0
  87. package/dist/icons/phosphor/duotone/style.css +12115 -0
  88. package/dist/icons/phosphor/fill/Phosphor-Fill.ttf +0 -0
  89. package/dist/icons/phosphor/fill/Phosphor-Fill.woff +0 -0
  90. package/dist/icons/phosphor/fill/Phosphor-Fill.woff2 +0 -0
  91. package/dist/icons/phosphor/fill/style.css +4627 -0
  92. package/dist/icons/phosphor/light/Phosphor-Light.ttf +0 -0
  93. package/dist/icons/phosphor/light/Phosphor-Light.woff +0 -0
  94. package/dist/icons/phosphor/light/Phosphor-Light.woff2 +0 -0
  95. package/dist/icons/phosphor/light/style.css +4627 -0
  96. package/dist/icons/phosphor/regular/Phosphor.ttf +0 -0
  97. package/dist/icons/phosphor/regular/Phosphor.woff +0 -0
  98. package/dist/icons/phosphor/regular/Phosphor.woff2 +0 -0
  99. package/dist/icons/phosphor/regular/style.css +4627 -0
  100. package/dist/icons/phosphor/thin/Phosphor-Thin.ttf +0 -0
  101. package/dist/icons/phosphor/thin/Phosphor-Thin.woff +0 -0
  102. package/dist/icons/phosphor/thin/Phosphor-Thin.woff2 +0 -0
  103. package/dist/icons/phosphor/thin/style.css +4627 -0
  104. package/dist/vanduo.cjs.js +6178 -0
  105. package/dist/vanduo.cjs.js.map +7 -0
  106. package/dist/vanduo.cjs.min.js +48 -0
  107. package/dist/vanduo.cjs.min.js.map +7 -0
  108. package/dist/vanduo.css +60950 -0
  109. package/dist/vanduo.css.map +1 -0
  110. package/dist/vanduo.esm.js +6157 -0
  111. package/dist/vanduo.esm.js.map +7 -0
  112. package/dist/vanduo.esm.min.js +48 -0
  113. package/dist/vanduo.esm.min.js.map +7 -0
  114. package/dist/vanduo.js +6154 -0
  115. package/dist/vanduo.js.map +7 -0
  116. package/dist/vanduo.min.css +2 -0
  117. package/dist/vanduo.min.css.map +1 -0
  118. package/dist/vanduo.min.js +48 -0
  119. package/dist/vanduo.min.js.map +7 -0
  120. package/fonts/fira-sans/fira-sans-bold.woff2 +0 -0
  121. package/fonts/fira-sans/fira-sans-medium.woff2 +0 -0
  122. package/fonts/fira-sans/fira-sans-regular.woff2 +0 -0
  123. package/fonts/ibm-plex/ibm-plex-sans-bold.woff2 +0 -0
  124. package/fonts/ibm-plex/ibm-plex-sans-medium.woff2 +0 -0
  125. package/fonts/ibm-plex/ibm-plex-sans-regular.woff2 +0 -0
  126. package/fonts/inter/inter-bold.woff2 +0 -0
  127. package/fonts/inter/inter-medium.woff2 +0 -0
  128. package/fonts/inter/inter-regular.woff2 +0 -0
  129. package/fonts/inter/inter-semibold.woff2 +0 -0
  130. package/fonts/jetbrains-mono/jetbrains-mono-bold.woff2 +0 -0
  131. package/fonts/jetbrains-mono/jetbrains-mono-regular.woff2 +0 -0
  132. package/fonts/open-sans/open-sans-bold.woff2 +0 -0
  133. package/fonts/open-sans/open-sans-medium.woff2 +0 -0
  134. package/fonts/open-sans/open-sans-regular.woff2 +0 -0
  135. package/fonts/rubik/rubik-bold.woff2 +0 -0
  136. package/fonts/rubik/rubik-medium.woff2 +0 -0
  137. package/fonts/rubik/rubik-regular.woff2 +0 -0
  138. package/fonts/source-sans/source-sans-bold.woff2 +0 -0
  139. package/fonts/source-sans/source-sans-regular.woff2 +0 -0
  140. package/fonts/source-sans/source-sans-semibold.woff2 +0 -0
  141. package/fonts/titillium-web/titillium-web-bold.woff2 +0 -0
  142. package/fonts/titillium-web/titillium-web-regular.woff2 +0 -0
  143. package/fonts/titillium-web/titillium-web-semibold.woff2 +0 -0
  144. package/fonts/ubuntu/ubuntu-bold.woff2 +0 -0
  145. package/fonts/ubuntu/ubuntu-medium.woff2 +0 -0
  146. package/fonts/ubuntu/ubuntu-regular.woff2 +0 -0
  147. package/icons/phosphor/LICENSE +21 -0
  148. package/icons/phosphor/bold/Phosphor-Bold.ttf +0 -0
  149. package/icons/phosphor/bold/Phosphor-Bold.woff +0 -0
  150. package/icons/phosphor/bold/Phosphor-Bold.woff2 +0 -0
  151. package/icons/phosphor/bold/style.css +4627 -0
  152. package/icons/phosphor/duotone/Phosphor-Duotone.ttf +0 -0
  153. package/icons/phosphor/duotone/Phosphor-Duotone.woff +0 -0
  154. package/icons/phosphor/duotone/Phosphor-Duotone.woff2 +0 -0
  155. package/icons/phosphor/duotone/style.css +12115 -0
  156. package/icons/phosphor/fill/Phosphor-Fill.ttf +0 -0
  157. package/icons/phosphor/fill/Phosphor-Fill.woff +0 -0
  158. package/icons/phosphor/fill/Phosphor-Fill.woff2 +0 -0
  159. package/icons/phosphor/fill/style.css +4627 -0
  160. package/icons/phosphor/light/Phosphor-Light.ttf +0 -0
  161. package/icons/phosphor/light/Phosphor-Light.woff +0 -0
  162. package/icons/phosphor/light/Phosphor-Light.woff2 +0 -0
  163. package/icons/phosphor/light/style.css +4627 -0
  164. package/icons/phosphor/regular/Phosphor.ttf +0 -0
  165. package/icons/phosphor/regular/Phosphor.woff +0 -0
  166. package/icons/phosphor/regular/Phosphor.woff2 +0 -0
  167. package/icons/phosphor/regular/style.css +4627 -0
  168. package/icons/phosphor/thin/Phosphor-Thin.ttf +0 -0
  169. package/icons/phosphor/thin/Phosphor-Thin.woff +0 -0
  170. package/icons/phosphor/thin/Phosphor-Thin.woff2 +0 -0
  171. package/icons/phosphor/thin/style.css +4627 -0
  172. package/js/components/code-snippet.js +641 -0
  173. package/js/components/collapsible.js +226 -0
  174. package/js/components/doc-search.js +953 -0
  175. package/js/components/draggable.js +728 -0
  176. package/js/components/dropdown.js +362 -0
  177. package/js/components/font-switcher.js +253 -0
  178. package/js/components/grid.js +279 -0
  179. package/js/components/image-box.js +372 -0
  180. package/js/components/lazy-load.js +353 -0
  181. package/js/components/modals.js +367 -0
  182. package/js/components/navbar.js +264 -0
  183. package/js/components/pagination.js +286 -0
  184. package/js/components/parallax.js +216 -0
  185. package/js/components/preloader.js +183 -0
  186. package/js/components/select.js +444 -0
  187. package/js/components/sidenav.js +303 -0
  188. package/js/components/tabs.js +303 -0
  189. package/js/components/theme-customizer.js +800 -0
  190. package/js/components/theme-switcher.js +183 -0
  191. package/js/components/toast.js +343 -0
  192. package/js/components/tooltips.js +306 -0
  193. package/js/index.js +53 -0
  194. package/js/utils/helpers.js +318 -0
  195. package/js/utils/lifecycle.js +135 -0
  196. package/js/vanduo.js +120 -0
  197. package/package.json +78 -0
@@ -0,0 +1,953 @@
1
+ /**
2
+ * Vanduo Framework - Search Component
3
+ * Client-side search functionality for content pages
4
+ *
5
+ * @example Basic usage (initialize with defaults)
6
+ * // HTML:
7
+ * // <div class="doc-search">
8
+ * // <input type="search" class="doc-search-input" placeholder="Search...">
9
+ * // <div class="vd-doc-search-results"></div>
10
+ * // </div>
11
+ *
12
+ * @example Custom configuration
13
+ * var search = Search.create({
14
+ * containerSelector: '.my-search',
15
+ * contentSelector: 'article[id]',
16
+ * titleSelector: 'h2, h3',
17
+ * maxResults: 5,
18
+ * onSelect: function(result) {
19
+ * console.log('Selected:', result.title);
20
+ * }
21
+ * });
22
+ *
23
+ * @example With custom data source
24
+ * var search = Search.create({
25
+ * containerSelector: '.my-search',
26
+ * data: [
27
+ * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },
28
+ * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }
29
+ * ]
30
+ * });
31
+ */
32
+
33
+ (function() {
34
+ 'use strict';
35
+
36
+ /**
37
+ * Default configuration
38
+ */
39
+ const DEFAULTS = {
40
+ // Behavior
41
+ minQueryLength: 2,
42
+ maxResults: 10,
43
+ debounceMs: 150,
44
+ highlightTag: 'mark',
45
+ keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut
46
+
47
+ // Selectors (for DOM-based indexing)
48
+ containerSelector: '.vd-doc-search',
49
+ inputSelector: '.vd-doc-search-input',
50
+ resultsSelector: '.vd-doc-search-results',
51
+ contentSelector: '.doc-content section[id]',
52
+ titleSelector: '.demo-title, h2, h3',
53
+ navSelector: '.doc-nav-link',
54
+ sectionSelector: '.doc-nav-section',
55
+
56
+ // Content extraction
57
+ excludeFromContent: 'pre, code, script, style',
58
+ maxContentLength: 500,
59
+
60
+ // Custom data source (alternative to DOM indexing)
61
+ data: null,
62
+
63
+ // Category icons mapping
64
+ categoryIcons: {
65
+ 'getting-started': 'ph-rocket-launch',
66
+ 'core': 'ph-cube',
67
+ 'components': 'ph-puzzle-piece',
68
+ 'interactive': 'ph-cursor-click',
69
+ 'data-display': 'ph-table',
70
+ 'feedback': 'ph-bell',
71
+ 'meta': 'ph-info',
72
+ 'default': 'ph-file-text'
73
+ },
74
+
75
+ // Callbacks
76
+ onSelect: null, // function(result) - called when result is selected
77
+ onSearch: null, // function(query, results) - called after search
78
+ onOpen: null, // function() - called when results open
79
+ onClose: null, // function() - called when results close
80
+
81
+ // Text customization
82
+ emptyTitle: 'No results found',
83
+ emptyText: 'Try different keywords or check spelling',
84
+ placeholder: 'Search...'
85
+ };
86
+
87
+ /**
88
+ * Search Component Factory
89
+ * Creates a new search instance with the given configuration
90
+ *
91
+ * @param {Object} options - Configuration options
92
+ * @returns {Object} Search instance
93
+ */
94
+ function createSearch(options) {
95
+ const config = Object.assign({}, DEFAULTS, options || {});
96
+
97
+ // Instance state
98
+ const state = {
99
+ initialized: false,
100
+ index: [],
101
+ results: [],
102
+ activeIndex: -1,
103
+ isOpen: false,
104
+ query: '',
105
+ container: null,
106
+ input: null,
107
+ resultsContainer: null,
108
+ debounceTimer: null,
109
+ boundHandlers: {}
110
+ };
111
+
112
+ function safeInvokeCallback(name, fn, ...args) {
113
+ try {
114
+ fn(...args);
115
+ } catch (error) {
116
+ console.warn('[Vanduo Search] Callback error in "' + name + '":', error);
117
+ }
118
+ }
119
+
120
+ function setResultsHtml(html) {
121
+ if (!state.resultsContainer) return;
122
+ try {
123
+ state.resultsContainer.innerHTML = html;
124
+ } catch (error) {
125
+ console.warn('[Vanduo Search] Failed to render results:', error);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Initialize the search component
131
+ * Idempotent — safe to call more than once on the same instance.
132
+ * Returns the instance on success, null if required DOM elements are missing.
133
+ */
134
+ function init() {
135
+ if (state.initialized) {
136
+ return instance;
137
+ }
138
+
139
+ state.container = document.querySelector(config.containerSelector);
140
+ if (!state.container) {
141
+ state.initialized = false;
142
+ return null;
143
+ }
144
+
145
+ state.input = state.container.querySelector(config.inputSelector);
146
+ state.resultsContainer = state.container.querySelector(config.resultsSelector);
147
+
148
+ if (!state.input || !state.resultsContainer) {
149
+ state.initialized = false;
150
+ return null;
151
+ }
152
+
153
+ // Set placeholder if configured
154
+ if (config.placeholder) {
155
+ state.input.setAttribute('placeholder', config.placeholder);
156
+ }
157
+
158
+ // Build search index
159
+ buildIndex();
160
+
161
+ // Bind events
162
+ bindEvents();
163
+
164
+ // Set up ARIA attributes
165
+ setupAria();
166
+
167
+ state.initialized = true;
168
+ return instance;
169
+ }
170
+
171
+ /**
172
+ * Build search index from DOM or custom data
173
+ */
174
+ function buildIndex() {
175
+ state.index = [];
176
+
177
+ // Use custom data if provided
178
+ if (config.data && Array.isArray(config.data)) {
179
+ config.data.forEach(function(item) {
180
+ state.index.push({
181
+ id: item.id || slugify(item.title),
182
+ title: item.title || '',
183
+ category: item.category || '',
184
+ categorySlug: slugify(item.category || ''),
185
+ content: item.content || '',
186
+ keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),
187
+ url: item.url || '#' + (item.id || slugify(item.title)),
188
+ icon: item.icon || ''
189
+ });
190
+ });
191
+ return;
192
+ }
193
+
194
+ // Build from DOM
195
+ const sections = document.querySelectorAll(config.contentSelector);
196
+ const categoryMap = buildCategoryMap();
197
+
198
+ sections.forEach(function(section) {
199
+ const id = section.id;
200
+ if (!id) return;
201
+
202
+ const titleEl = section.querySelector(config.titleSelector);
203
+ const title = titleEl ? titleEl.textContent.replace(/v[\d.]+/g, '').trim() : id;
204
+ const category = categoryMap[id] || 'Documentation';
205
+ const content = extractContent(section);
206
+ const keywords = extractKeywords(section, title);
207
+ const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;
208
+ let icon = '';
209
+ if (iconEl && iconEl.classList) {
210
+ for (let ci = 0; ci < iconEl.classList.length; ci++) {
211
+ if (iconEl.classList[ci].indexOf('ph-') === 0) {
212
+ icon = iconEl.classList[ci];
213
+ break;
214
+ }
215
+ }
216
+ }
217
+
218
+ state.index.push({
219
+ id: id,
220
+ title: title,
221
+ category: category,
222
+ categorySlug: slugify(category),
223
+ content: content,
224
+ keywords: keywords,
225
+ url: '#' + id,
226
+ icon: icon
227
+ });
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Build a map of section IDs to their categories
233
+ */
234
+ function buildCategoryMap() {
235
+ const map = {};
236
+ let currentCategory = 'Documentation';
237
+ const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);
238
+
239
+ navItems.forEach(function(item) {
240
+ if (item.classList.contains('doc-nav-section')) {
241
+ currentCategory = item.textContent.trim();
242
+ } else {
243
+ const href = item.getAttribute('href');
244
+ if (href && href.startsWith('#')) {
245
+ const id = href.substring(1);
246
+ map[id] = currentCategory;
247
+ }
248
+ }
249
+ });
250
+
251
+ return map;
252
+ }
253
+
254
+ /**
255
+ * Extract searchable content from a section
256
+ */
257
+ function extractContent(section) {
258
+ const clone = section.cloneNode(true);
259
+
260
+ const toRemove = clone.querySelectorAll(config.excludeFromContent);
261
+ toRemove.forEach(function(el) {
262
+ el.remove();
263
+ });
264
+
265
+ let text = clone.textContent || '';
266
+ text = text.replace(/\s+/g, ' ').trim();
267
+
268
+ return text.substring(0, config.maxContentLength);
269
+ }
270
+
271
+ /**
272
+ * Extract keywords from a section
273
+ */
274
+ function extractKeywords(section, title) {
275
+ const keywords = [];
276
+
277
+ // Add title words
278
+ title.toLowerCase().split(/\s+/).forEach(function(word) {
279
+ if (word.length > 2) {
280
+ keywords.push(word);
281
+ }
282
+ });
283
+
284
+ // Add class names from code examples
285
+ const codeBlocks = section.querySelectorAll('code');
286
+ codeBlocks.forEach(function(code) {
287
+ const text = code.textContent || '';
288
+ const classMatches = text.match(/\.([\w-]+)/g);
289
+ if (classMatches) {
290
+ classMatches.forEach(function(match) {
291
+ keywords.push(match.substring(1).toLowerCase());
292
+ });
293
+ }
294
+ });
295
+
296
+ // Add data attributes
297
+ const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');
298
+ dataAttrs.forEach(function(el) {
299
+ const attrs = el.getAttributeNames().filter(function(name) {
300
+ return name.startsWith('data-');
301
+ });
302
+ attrs.forEach(function(attr) {
303
+ keywords.push(attr.replace('data-', ''));
304
+ });
305
+ });
306
+
307
+ return Array.from(new Set(keywords));
308
+ }
309
+
310
+ /**
311
+ * Extract keywords from text string
312
+ */
313
+ function extractKeywordsFromText(text) {
314
+ const words = text.toLowerCase().split(/\s+/);
315
+ return words.filter(function(word) {
316
+ return word.length > 2;
317
+ });
318
+ }
319
+
320
+ /**
321
+ * Convert string to slug
322
+ */
323
+ function slugify(str) {
324
+ return str.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
325
+ }
326
+
327
+ /**
328
+ * Bind event listeners
329
+ */
330
+ function bindEvents() {
331
+ // Store bound handlers for cleanup
332
+ state.boundHandlers.handleInput = function(e) {
333
+ handleInput(e);
334
+ };
335
+ state.boundHandlers.handleFocus = function() {
336
+ if (state.query.length >= config.minQueryLength) {
337
+ open();
338
+ }
339
+ };
340
+ state.boundHandlers.handleKeydown = function(e) {
341
+ handleKeydown(e);
342
+ };
343
+ state.boundHandlers.handleOutsideClick = function(e) {
344
+ if (!state.container.contains(e.target)) {
345
+ close();
346
+ }
347
+ };
348
+ state.boundHandlers.handleGlobalKeydown = function(e) {
349
+ if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {
350
+ e.preventDefault();
351
+ state.input.focus();
352
+ state.input.select();
353
+ }
354
+ };
355
+ state.boundHandlers.handleResultClick = function(e) {
356
+ const result = e.target.closest('.vd-doc-search-result');
357
+ if (result) {
358
+ const index = parseInt(result.dataset.index, 10);
359
+ select(index);
360
+ }
361
+ };
362
+
363
+ // Input events
364
+ state.input.addEventListener('input', state.boundHandlers.handleInput);
365
+ state.input.addEventListener('focus', state.boundHandlers.handleFocus);
366
+ state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);
367
+
368
+ // Close on outside click
369
+ document.addEventListener('click', state.boundHandlers.handleOutsideClick);
370
+
371
+ // Global keyboard shortcut
372
+ document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);
373
+
374
+ // Result click handling
375
+ state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);
376
+ }
377
+
378
+ /**
379
+ * Unbind event listeners
380
+ */
381
+ function unbindEvents() {
382
+ if (state.input) {
383
+ state.input.removeEventListener('input', state.boundHandlers.handleInput);
384
+ state.input.removeEventListener('focus', state.boundHandlers.handleFocus);
385
+ state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);
386
+ }
387
+ document.removeEventListener('click', state.boundHandlers.handleOutsideClick);
388
+ document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);
389
+ if (state.resultsContainer) {
390
+ state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Set up ARIA attributes
396
+ */
397
+ function setupAria() {
398
+ const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);
399
+ state.resultsContainer.id = resultsId;
400
+
401
+ state.input.setAttribute('role', 'combobox');
402
+ state.input.setAttribute('aria-autocomplete', 'list');
403
+ state.input.setAttribute('aria-controls', resultsId);
404
+ state.input.setAttribute('aria-expanded', 'false');
405
+
406
+ state.resultsContainer.setAttribute('role', 'listbox');
407
+ state.resultsContainer.setAttribute('aria-label', 'Search results');
408
+ }
409
+
410
+ /**
411
+ * Handle input changes
412
+ */
413
+ function handleInput(e) {
414
+ const query = e.target.value.trim();
415
+
416
+ if (state.debounceTimer) {
417
+ clearTimeout(state.debounceTimer);
418
+ }
419
+
420
+ state.debounceTimer = setTimeout(function() {
421
+ state.query = query;
422
+
423
+ if (query.length < config.minQueryLength) {
424
+ close();
425
+ return;
426
+ }
427
+
428
+ state.results = search(query);
429
+ state.activeIndex = -1;
430
+ render();
431
+ open();
432
+
433
+ // Callback
434
+ if (typeof config.onSearch === 'function') {
435
+ safeInvokeCallback('onSearch', config.onSearch, query, state.results);
436
+ }
437
+ }, config.debounceMs);
438
+ }
439
+
440
+ /**
441
+ * Handle keyboard navigation
442
+ */
443
+ function handleKeydown(e) {
444
+ if (!state.isOpen) {
445
+ if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {
446
+ e.preventDefault();
447
+ state.results = search(state.query);
448
+ render();
449
+ open();
450
+ }
451
+ return;
452
+ }
453
+
454
+ switch (e.key) {
455
+ case 'ArrowDown':
456
+ e.preventDefault();
457
+ navigate(1);
458
+ break;
459
+
460
+ case 'ArrowUp':
461
+ e.preventDefault();
462
+ navigate(-1);
463
+ break;
464
+
465
+ case 'Enter':
466
+ e.preventDefault();
467
+ if (state.activeIndex >= 0) {
468
+ select(state.activeIndex);
469
+ } else if (state.results.length > 0) {
470
+ select(0);
471
+ }
472
+ break;
473
+
474
+ case 'Escape':
475
+ e.preventDefault();
476
+ close();
477
+ break;
478
+
479
+ case 'Tab':
480
+ close();
481
+ break;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Perform search
487
+ */
488
+ function search(query) {
489
+ const terms = query.toLowerCase().split(/\s+/).filter(function(t) {
490
+ return t.length > 0;
491
+ });
492
+ const scored = [];
493
+
494
+ state.index.forEach(function(entry) {
495
+ let score = 0;
496
+ const titleLower = entry.title.toLowerCase();
497
+ const categoryLower = entry.category.toLowerCase();
498
+ const contentLower = entry.content.toLowerCase();
499
+
500
+ terms.forEach(function(term) {
501
+ // Title match - highest priority
502
+ if (titleLower.includes(term)) {
503
+ score += 100;
504
+ if (titleLower === term) {
505
+ score += 50;
506
+ } else if (titleLower.startsWith(term)) {
507
+ score += 25;
508
+ }
509
+ }
510
+
511
+ // Category match
512
+ if (categoryLower.includes(term)) {
513
+ score += 50;
514
+ }
515
+
516
+ // Keyword match
517
+ const keywordMatch = entry.keywords.some(function(k) {
518
+ return k.includes(term);
519
+ });
520
+ if (keywordMatch) {
521
+ score += 30;
522
+ }
523
+
524
+ // Content match
525
+ if (contentLower.includes(term)) {
526
+ score += 10;
527
+ }
528
+ });
529
+
530
+ if (score > 0) {
531
+ scored.push({
532
+ id: entry.id,
533
+ title: entry.title,
534
+ category: entry.category,
535
+ categorySlug: entry.categorySlug,
536
+ content: entry.content,
537
+ url: entry.url,
538
+ icon: entry.icon,
539
+ score: score
540
+ });
541
+ }
542
+ });
543
+
544
+ scored.sort(function(a, b) {
545
+ return b.score - a.score;
546
+ });
547
+
548
+ return scored.slice(0, config.maxResults);
549
+ }
550
+
551
+ /**
552
+ * Render search results
553
+ */
554
+ function render() {
555
+ if (state.results.length === 0) {
556
+ setResultsHtml(renderEmpty());
557
+ return;
558
+ }
559
+
560
+ let html = '<ul class="vd-doc-search-results-list" role="listbox">';
561
+
562
+ state.results.forEach(function(result, index) {
563
+ const isActive = index === state.activeIndex;
564
+ const icon = result.icon || getCategoryIcon(result.categorySlug);
565
+ const excerpt = getExcerpt(result.content, state.query);
566
+
567
+ html += '<li class="vd-doc-search-result' + (isActive ? ' is-active' : '') + '"' +
568
+ ' role="option"' +
569
+ ' id="vd-doc-search-result-' + index + '"' +
570
+ ' data-index="' + index + '"' +
571
+ ' data-category="' + escapeHtml(result.categorySlug) + '"' +
572
+ ' aria-selected="' + isActive + '"' +
573
+ '>' +
574
+ '<div class="vd-doc-search-result-icon">' +
575
+ '<i class="ph ' + escapeHtml(icon) + '"></i>' +
576
+ '</div>' +
577
+ '<div class="vd-doc-search-result-content">' +
578
+ '<div class="vd-doc-search-result-title">' + highlight(result.title, state.query) + '</div>' +
579
+ '<div class="vd-doc-search-result-category">' + escapeHtml(result.category) + '</div>' +
580
+ '<div class="vd-doc-search-result-excerpt">' + highlight(excerpt, state.query) + '</div>' +
581
+ '</div>' +
582
+ '</li>';
583
+ });
584
+
585
+ html += '</ul>';
586
+ html += renderFooter();
587
+
588
+ setResultsHtml(html);
589
+ }
590
+
591
+ /**
592
+ * Render empty state
593
+ */
594
+ function renderEmpty() {
595
+ return '<div class="vd-doc-search-empty">' +
596
+ '<div class="vd-doc-search-empty-icon"><i class="ph ph-magnifying-glass"></i></div>' +
597
+ '<div class="vd-doc-search-empty-title">' + escapeHtml(config.emptyTitle) + '</div>' +
598
+ '<div class="vd-doc-search-empty-text">' + escapeHtml(config.emptyText) + '</div>' +
599
+ '</div>';
600
+ }
601
+
602
+ /**
603
+ * Render footer with keyboard hints
604
+ */
605
+ function renderFooter() {
606
+ return '<div class="vd-doc-search-footer">' +
607
+ '<span class="vd-doc-search-footer-item"><kbd>↑</kbd><kbd>↓</kbd> to navigate</span>' +
608
+ '<span class="vd-doc-search-footer-item"><kbd>↵</kbd> to select</span>' +
609
+ '<span class="vd-doc-search-footer-item"><kbd>esc</kbd> to close</span>' +
610
+ '</div>';
611
+ }
612
+
613
+ /**
614
+ * Get icon for category
615
+ */
616
+ function getCategoryIcon(categorySlug) {
617
+ return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';
618
+ }
619
+
620
+ /**
621
+ * Get excerpt from content
622
+ */
623
+ function getExcerpt(content, query) {
624
+ const terms = query.toLowerCase().split(/\s+/);
625
+ const contentLower = content.toLowerCase();
626
+ const excerptLength = 100;
627
+
628
+ let matchPos = -1;
629
+ for (let i = 0; i < terms.length; i++) {
630
+ const pos = contentLower.indexOf(terms[i]);
631
+ if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {
632
+ matchPos = pos;
633
+ }
634
+ }
635
+
636
+ if (matchPos === -1) {
637
+ return content.substring(0, excerptLength) + '...';
638
+ }
639
+
640
+ const start = Math.max(0, matchPos - 30);
641
+ const end = Math.min(content.length, matchPos + excerptLength);
642
+ let excerpt = content.substring(start, end);
643
+
644
+ if (start > 0) {
645
+ excerpt = '...' + excerpt;
646
+ }
647
+ if (end < content.length) {
648
+ excerpt = excerpt + '...';
649
+ }
650
+
651
+ return excerpt;
652
+ }
653
+
654
+ /**
655
+ * Highlight matched terms in text
656
+ */
657
+ function highlight(text, query) {
658
+ if (!query) return escapeHtml(text);
659
+
660
+ const terms = query.toLowerCase().split(/\s+/).filter(function(t) {
661
+ return t.length > 0;
662
+ });
663
+ let escaped = escapeHtml(text);
664
+
665
+ terms.forEach(function(term) {
666
+ // Skip overly long terms to prevent ReDoS
667
+ if (term.length > 50) return;
668
+ const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
669
+ escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1</' + config.highlightTag + '>');
670
+ });
671
+
672
+ return escaped;
673
+ }
674
+
675
+ /**
676
+ * Escape HTML entities
677
+ */
678
+ function escapeHtml(text) {
679
+ const div = document.createElement('div');
680
+ div.textContent = text;
681
+ return div.innerHTML;
682
+ }
683
+
684
+ /**
685
+ * Navigate results with keyboard
686
+ */
687
+ function navigate(direction) {
688
+ let newIndex = state.activeIndex + direction;
689
+
690
+ if (newIndex < 0) {
691
+ newIndex = state.results.length - 1;
692
+ } else if (newIndex >= state.results.length) {
693
+ newIndex = 0;
694
+ }
695
+
696
+ setActiveIndex(newIndex);
697
+ }
698
+
699
+ /**
700
+ * Set active result index
701
+ */
702
+ function setActiveIndex(index) {
703
+ const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');
704
+ if (prevActive) {
705
+ prevActive.classList.remove('is-active');
706
+ prevActive.setAttribute('aria-selected', 'false');
707
+ }
708
+
709
+ state.activeIndex = index;
710
+
711
+ const newActive = state.resultsContainer.querySelector('[data-index="' + index + '"]');
712
+ if (newActive) {
713
+ newActive.classList.add('is-active');
714
+ newActive.setAttribute('aria-selected', 'true');
715
+ state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);
716
+ newActive.scrollIntoView({ block: 'nearest' });
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Select a result
722
+ */
723
+ function select(index) {
724
+ const result = state.results[index];
725
+ if (!result) return;
726
+
727
+ // Close search
728
+ close();
729
+ state.input.value = '';
730
+ state.query = '';
731
+
732
+ // Custom callback
733
+ if (typeof config.onSelect === 'function') {
734
+ safeInvokeCallback('onSelect', config.onSelect, result);
735
+ return;
736
+ }
737
+
738
+ // Default behavior: navigate to section
739
+ const section = document.querySelector(result.url);
740
+ if (section) {
741
+ section.scrollIntoView({ behavior: 'smooth', block: 'start' });
742
+ window.history.pushState(null, '', result.url);
743
+ updateSidebarActive(result.id);
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Update sidebar navigation active state
749
+ */
750
+ function updateSidebarActive(sectionId) {
751
+ const navLinks = document.querySelectorAll(config.navSelector);
752
+ navLinks.forEach(function(link) {
753
+ link.classList.remove('active');
754
+ if (link.getAttribute('href') === '#' + sectionId) {
755
+ link.classList.add('active');
756
+ }
757
+ });
758
+ }
759
+
760
+ /**
761
+ * Open results dropdown
762
+ */
763
+ function open() {
764
+ if (state.isOpen) return;
765
+
766
+ state.isOpen = true;
767
+ state.resultsContainer.classList.add('is-open');
768
+ state.input.setAttribute('aria-expanded', 'true');
769
+
770
+ if (typeof config.onOpen === 'function') {
771
+ safeInvokeCallback('onOpen', config.onOpen);
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Close results dropdown
777
+ */
778
+ function close() {
779
+ if (!state.isOpen) return;
780
+
781
+ state.isOpen = false;
782
+ state.activeIndex = -1;
783
+ state.resultsContainer.classList.remove('is-open');
784
+ state.input.setAttribute('aria-expanded', 'false');
785
+ state.input.removeAttribute('aria-activedescendant');
786
+
787
+ if (typeof config.onClose === 'function') {
788
+ safeInvokeCallback('onClose', config.onClose);
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Destroy the component
794
+ */
795
+ function destroy() {
796
+ unbindEvents();
797
+
798
+ state.initialized = false;
799
+ state.index = [];
800
+ state.results = [];
801
+ state.isOpen = false;
802
+ state.query = '';
803
+
804
+ if (state.debounceTimer) {
805
+ clearTimeout(state.debounceTimer);
806
+ }
807
+
808
+ if (state.resultsContainer) {
809
+ setResultsHtml('');
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Rebuild the search index
815
+ */
816
+ function rebuild() {
817
+ buildIndex();
818
+ }
819
+
820
+ /**
821
+ * Update configuration
822
+ */
823
+ function setConfig(newConfig) {
824
+ Object.assign(config, newConfig);
825
+ }
826
+
827
+ /**
828
+ * Get current configuration
829
+ */
830
+ function getConfig() {
831
+ return Object.assign({}, config);
832
+ }
833
+
834
+ /**
835
+ * Get search index
836
+ */
837
+ function getIndex() {
838
+ return state.index.slice();
839
+ }
840
+
841
+ // Public instance API
842
+ const instance = {
843
+ init: init,
844
+ destroy: destroy,
845
+ rebuild: rebuild,
846
+ search: search,
847
+ open: open,
848
+ close: close,
849
+ setConfig: setConfig,
850
+ getConfig: getConfig,
851
+ getIndex: getIndex
852
+ };
853
+
854
+ return instance;
855
+ }
856
+
857
+ /**
858
+ * Search Component (singleton for backward compatibility)
859
+ */
860
+ const Search = {
861
+ // Factory method — creates and auto-initializes a new independent instance.
862
+ // Always returns the instance so callers retain a reference even if the
863
+ // DOM container is not yet available (they can retry init() later).
864
+ create: function(options) {
865
+ const instance = createSearch(options);
866
+ if (instance) {
867
+ instance.init();
868
+ }
869
+ return instance || null;
870
+ },
871
+
872
+ // Default instance
873
+ _instance: null,
874
+
875
+ // Configuration (for default instance)
876
+ config: Object.assign({}, DEFAULTS),
877
+
878
+ /**
879
+ * Initialize the default search instance
880
+ */
881
+ init: function(options) {
882
+ if (this._instance) {
883
+ this._instance.destroy();
884
+ }
885
+
886
+ if (options) {
887
+ Object.assign(this.config, options);
888
+ }
889
+
890
+ this._instance = createSearch(this.config);
891
+ return this._instance ? this._instance.init() : null;
892
+ },
893
+
894
+ /**
895
+ * Destroy the default instance
896
+ */
897
+ destroy: function() {
898
+ if (this._instance) {
899
+ this._instance.destroy();
900
+ this._instance = null;
901
+ }
902
+ },
903
+
904
+ destroyAll: function() {
905
+ this.destroy();
906
+ },
907
+
908
+ /**
909
+ * Rebuild the default instance index
910
+ */
911
+ rebuild: function() {
912
+ if (this._instance) {
913
+ this._instance.rebuild();
914
+ }
915
+ },
916
+
917
+ /**
918
+ * Search using the default instance
919
+ */
920
+ search: function(query) {
921
+ return this._instance ? this._instance.search(query) : [];
922
+ },
923
+
924
+ /**
925
+ * Open the default instance
926
+ */
927
+ open: function() {
928
+ if (this._instance) {
929
+ this._instance.open();
930
+ }
931
+ },
932
+
933
+ /**
934
+ * Close the default instance
935
+ */
936
+ close: function() {
937
+ if (this._instance) {
938
+ this._instance.close();
939
+ }
940
+ }
941
+ };
942
+
943
+ // Register with Vanduo framework if available
944
+ if (typeof window.Vanduo !== 'undefined') {
945
+ window.Vanduo.register('docSearch', Search);
946
+ }
947
+
948
+ // Expose globally (both names for compatibility)
949
+ window.Search = Search;
950
+ window.DocSearch = Search; // Backward compatibility
951
+ window.VanduoDocSearch = Search; // New name compatibility
952
+
953
+ })();