vanduo-framework 1.1.8

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