lucos_search_component 1.0.51 → 1.0.53

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.js CHANGED
@@ -5646,7 +5646,7 @@ var tomSelectStylesheet = "/**\n * tom-select.css (v2.5.2)\n * Copyright (c) con
5646
5646
 
5647
5647
  class LucosSearchComponent extends HTMLSpanElement {
5648
5648
  static get observedAttributes() {
5649
- return ['data-api-key','data-types','data-exclude-types','data-no-lang'];
5649
+ return ['data-api-key','data-types','data-exclude-types','data-no-lang','data-common'];
5650
5650
  }
5651
5651
  constructor() {
5652
5652
  super();
@@ -5794,13 +5794,20 @@ class LucosSearchComponent extends HTMLSpanElement {
5794
5794
  if (!selector) throw new Error("Can't find select element in lucos-search");
5795
5795
  selector.setAttribute("multiple", "multiple");
5796
5796
  new TomSelect(selector, {
5797
- ...(component.isLanguageMode ? { optgroupField: 'lang_family', lockOptgroupOrder: true } : {}),
5797
+ ...(component.isLanguageMode || component.getAttribute("data-common") ? { optgroupField: 'lang_family', lockOptgroupOrder: true } : {}),
5798
5798
  valueField: 'id',
5799
5799
  labelField: 'pref_label',
5800
5800
  searchField: [],
5801
5801
  closeAfterSelect: true,
5802
5802
  highlight: false, // Will use typesense's hightlight (as it can consider other fields)
5803
5803
  load: async function(query, callback) {
5804
+ // Cancel any in-flight search so stale responses don't overwrite newer results
5805
+ if (component._searchAbortController) {
5806
+ component._searchAbortController.abort();
5807
+ }
5808
+ const abortController = new AbortController();
5809
+ component._searchAbortController = abortController;
5810
+
5804
5811
  errorMessage.setAttribute('hidden', '');
5805
5812
  const queryParams = new URLSearchParams({
5806
5813
  q: query,
@@ -5811,15 +5818,25 @@ class LucosSearchComponent extends HTMLSpanElement {
5811
5818
  queryParams.set("filter_by",`type:![${component.getAttribute("data-exclude_types")}]`);
5812
5819
  }
5813
5820
  try {
5814
- const results = await component.searchRequest(queryParams);
5821
+ let results = await component.searchRequest(queryParams, abortController.signal);
5822
+ if (abortController.signal.aborted) return;
5815
5823
  this.clearOptions();
5816
5824
  if (component.isLanguageMode) {
5817
5825
  results.forEach(r => { if (!r.lang_family) r.lang_family = 'qli'; });
5818
5826
  }
5827
+ // Remove common items from results to avoid duplication (they're always shown separately)
5828
+ if (component._commonOptions) {
5829
+ const commonIds = new Set(component._commonOptions.map(o => o.id));
5830
+ results = results.filter(r => !commonIds.has(r.id));
5831
+ component._commonOptions.forEach(opt => this.addOption(opt));
5832
+ }
5819
5833
  const noLang = component.noLangOption;
5820
- if (noLang) results.unshift(noLang);
5834
+ // Don't add noLang as standalone if it's already covered by a common item
5835
+ const noLangIsCommon = noLang && component._commonOptions && component._commonOptions.some(o => o.id === noLang.id);
5836
+ if (noLang && !noLangIsCommon) results.unshift(noLang);
5821
5837
  callback(results);
5822
5838
  } catch(err) {
5839
+ if (err.name === 'AbortError') return;
5823
5840
  callback([]);
5824
5841
  errorMessage.textContent = err.userMessage || 'Search is currently unavailable — please try again later.';
5825
5842
  errorMessage.removeAttribute('hidden');
@@ -5837,15 +5854,35 @@ class LucosSearchComponent extends HTMLSpanElement {
5837
5854
  },
5838
5855
  onFocus: function() {
5839
5856
  this.clearOptions();
5857
+ // Re-add common items first so they own any shared IDs (e.g. zxx in both data-no-lang and data-common)
5858
+ if (component._commonOptions) {
5859
+ component._commonOptions.forEach(opt => this.addOption(opt));
5860
+ }
5840
5861
  const noLang = component.noLangOption;
5841
- if (noLang) this.addOption(noLang);
5862
+ // Skip noLang if its ID is already a common item (would be silently discarded as a duplicate)
5863
+ const noLangIsCommon = noLang && component._commonOptions && component._commonOptions.some(o => o.id === noLang.id);
5864
+ if (noLang && !noLangIsCommon) this.addOption(noLang);
5842
5865
  },
5843
5866
  // On startup, update any existing options with latest data from search
5844
5867
  onInitialize: async function() {
5845
5868
  const ids = Object.keys(this.options);
5846
5869
  const noLang = component.noLangOption;
5847
- // Always make the no-lang option available for new selections
5848
- if (noLang) this.addOption(noLang);
5870
+ // Fetch and register common items (x-common group goes first)
5871
+ const commonIds = component.commonIds;
5872
+ if (commonIds.length > 0) {
5873
+ this.addOptionGroup('x-common', { label: 'Common' });
5874
+ const commonParams = new URLSearchParams({
5875
+ q: '*',
5876
+ filter_by: `id:[${commonIds.join(",")}]`,
5877
+ per_page: commonIds.length,
5878
+ });
5879
+ const commonResults = await component.searchRequest(commonParams);
5880
+ component._commonOptions = commonResults.map(r => ({...r, lang_family: 'x-common'}));
5881
+ component._commonOptions.forEach(opt => this.addOption(opt));
5882
+ }
5883
+ // Add noLang option now (after common items) so we can check for overlap
5884
+ const noLangIsCommon = noLang && component._commonOptions && component._commonOptions.some(o => o.id === noLang.id);
5885
+ if (noLang && !noLangIsCommon) this.addOption(noLang);
5849
5886
  // In language mode, fetch families and register option groups
5850
5887
  if (component.isLanguageMode) {
5851
5888
  const families = await component.getLanguageFamilies();
@@ -5855,8 +5892,9 @@ class LucosSearchComponent extends HTMLSpanElement {
5855
5892
  });
5856
5893
  }
5857
5894
  if (ids.length < 1) return;
5858
- // Fetch real options from Typesense, excluding the synthetic no-lang option
5859
- const idsToFetch = noLang ? ids.filter(id => id !== noLang.id) : ids;
5895
+ // Fetch real options from Typesense, excluding the synthetic no-lang option and common items
5896
+ const excludeIds = new Set([...(noLang ? [noLang.id] : []), ...commonIds]);
5897
+ const idsToFetch = ids.filter(id => !excludeIds.has(id));
5860
5898
  if (idsToFetch.length > 0) {
5861
5899
  const searchParams = new URLSearchParams({
5862
5900
  q: '*',
@@ -5873,6 +5911,12 @@ class LucosSearchComponent extends HTMLSpanElement {
5873
5911
  if (noLang && ids.includes(noLang.id)) {
5874
5912
  this.updateOption(noLang.id, noLang);
5875
5913
  }
5914
+ // Update any pre-selected common items with fresh data
5915
+ if (component._commonOptions) {
5916
+ component._commonOptions.forEach(opt => {
5917
+ if (ids.includes(opt.id)) this.updateOption(opt.id, opt);
5918
+ });
5919
+ }
5876
5920
  },
5877
5921
  onItemSelect: function (item) {
5878
5922
  // Tom-select prevents clicking on link in an item to work as normal, so force it here
@@ -5919,6 +5963,11 @@ class LucosSearchComponent extends HTMLSpanElement {
5919
5963
  if (!types) return false;
5920
5964
  return types.split(",").map(t => t.trim()).includes("Language");
5921
5965
  }
5966
+ get commonIds() {
5967
+ const common = this.getAttribute("data-common");
5968
+ if (!common) return [];
5969
+ return common.split(",").map(s => s.trim()).filter(Boolean);
5970
+ }
5922
5971
  async getLanguageFamilies() {
5923
5972
  if (this._langFamilies) return this._langFamilies;
5924
5973
  const key = this.getAttribute("data-api-key");
@@ -5952,7 +6001,7 @@ class LucosSearchComponent extends HTMLSpanElement {
5952
6001
  }
5953
6002
  return this._langFamilies;
5954
6003
  }
5955
- async searchRequest(searchParams) {
6004
+ async searchRequest(searchParams, abortSignal = null) {
5956
6005
  const key = this.getAttribute("data-api-key");
5957
6006
  if (!key) throw new Error("No `data-api-key` attribute set on `lucos-search` component");
5958
6007
  searchParams.set('query_by', "pref_label,labels,description,lyrics");
@@ -5963,13 +6012,16 @@ class LucosSearchComponent extends HTMLSpanElement {
5963
6012
  searchParams.set('enable_highlight_v1', false);
5964
6013
  searchParams.set('highlight_start_tag', '<span class="highlight">');
5965
6014
  searchParams.set('highlight_end_tag', '</span>');
6015
+ const timeoutSignal = AbortSignal.timeout(8000);
6016
+ const signal = abortSignal ? AbortSignal.any([timeoutSignal, abortSignal]) : timeoutSignal;
5966
6017
  let response;
5967
6018
  try {
5968
6019
  response = await fetch("https://arachne.l42.eu/search?"+searchParams.toString(), {
5969
6020
  headers: { 'X-TYPESENSE-API-KEY': key },
5970
- signal: AbortSignal.timeout(8000),
6021
+ signal,
5971
6022
  });
5972
6023
  } catch(err) {
6024
+ if (err.name === 'AbortError') throw err; // Pass through so caller can detect cancellation
5973
6025
  const userMessage = err.name === 'TimeoutError'
5974
6026
  ? 'Search timed out — please try again later.'
5975
6027
  : 'Search is currently unavailable — please try again later.';
@@ -14,6 +14,7 @@
14
14
  <option selected>https://eolas.l42.eu/metadata/language/ain/</option>
15
15
  </select></span>
16
16
  <label for="search8">Languages grouped by family:</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-types="Language"><select id="search8"></select></span>
17
+ <label for="search9">Languages with common pinned:</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-types="Language" data-no-lang="No Language" data-common="https://eolas.l42.eu/metadata/language/en/,https://eolas.l42.eu/metadata/language/ga/,https://eolas.l42.eu/metadata/language/zxx/"><select id="search9"></select></span>
17
18
  <label for="search6">Languages with no-lang option:</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-types="Language" data-no-lang="No Language"><select id="search6"></select></span>
18
19
  <label for="search7">Languages with no-lang pre-selected:</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-types="Language" data-no-lang="No Language"><select id="search7" multiple><option selected>https://eolas.l42.eu/metadata/language/zxx/</option></select></span>
19
20
  <label for="search5">More than 10:</label>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lucos_search_component",
3
- "version": "1.0.51",
3
+ "version": "1.0.53",
4
4
  "description": "Web Components for searching lucOS data",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -3,7 +3,7 @@ import tomSelectStylesheet from 'tom-select/dist/css/tom-select.default.css';
3
3
 
4
4
  class LucosSearchComponent extends HTMLSpanElement {
5
5
  static get observedAttributes() {
6
- return ['data-api-key','data-types','data-exclude-types','data-no-lang'];
6
+ return ['data-api-key','data-types','data-exclude-types','data-no-lang','data-common'];
7
7
  }
8
8
  constructor() {
9
9
  super();
@@ -151,13 +151,20 @@ class LucosSearchComponent extends HTMLSpanElement {
151
151
  if (!selector) throw new Error("Can't find select element in lucos-search");
152
152
  selector.setAttribute("multiple", "multiple");
153
153
  new TomSelect(selector, {
154
- ...(component.isLanguageMode ? { optgroupField: 'lang_family', lockOptgroupOrder: true } : {}),
154
+ ...(component.isLanguageMode || component.getAttribute("data-common") ? { optgroupField: 'lang_family', lockOptgroupOrder: true } : {}),
155
155
  valueField: 'id',
156
156
  labelField: 'pref_label',
157
157
  searchField: [],
158
158
  closeAfterSelect: true,
159
159
  highlight: false, // Will use typesense's hightlight (as it can consider other fields)
160
160
  load: async function(query, callback) {
161
+ // Cancel any in-flight search so stale responses don't overwrite newer results
162
+ if (component._searchAbortController) {
163
+ component._searchAbortController.abort();
164
+ }
165
+ const abortController = new AbortController();
166
+ component._searchAbortController = abortController;
167
+
161
168
  errorMessage.setAttribute('hidden', '');
162
169
  const queryParams = new URLSearchParams({
163
170
  q: query,
@@ -168,15 +175,25 @@ class LucosSearchComponent extends HTMLSpanElement {
168
175
  queryParams.set("filter_by",`type:![${component.getAttribute("data-exclude_types")}]`);
169
176
  }
170
177
  try {
171
- const results = await component.searchRequest(queryParams);
178
+ let results = await component.searchRequest(queryParams, abortController.signal);
179
+ if (abortController.signal.aborted) return;
172
180
  this.clearOptions();
173
181
  if (component.isLanguageMode) {
174
182
  results.forEach(r => { if (!r.lang_family) r.lang_family = 'qli'; });
175
183
  }
184
+ // Remove common items from results to avoid duplication (they're always shown separately)
185
+ if (component._commonOptions) {
186
+ const commonIds = new Set(component._commonOptions.map(o => o.id));
187
+ results = results.filter(r => !commonIds.has(r.id));
188
+ component._commonOptions.forEach(opt => this.addOption(opt));
189
+ }
176
190
  const noLang = component.noLangOption;
177
- if (noLang) results.unshift(noLang);
191
+ // Don't add noLang as standalone if it's already covered by a common item
192
+ const noLangIsCommon = noLang && component._commonOptions && component._commonOptions.some(o => o.id === noLang.id);
193
+ if (noLang && !noLangIsCommon) results.unshift(noLang);
178
194
  callback(results);
179
195
  } catch(err) {
196
+ if (err.name === 'AbortError') return;
180
197
  callback([]);
181
198
  errorMessage.textContent = err.userMessage || 'Search is currently unavailable — please try again later.';
182
199
  errorMessage.removeAttribute('hidden');
@@ -194,15 +211,35 @@ class LucosSearchComponent extends HTMLSpanElement {
194
211
  },
195
212
  onFocus: function() {
196
213
  this.clearOptions();
214
+ // Re-add common items first so they own any shared IDs (e.g. zxx in both data-no-lang and data-common)
215
+ if (component._commonOptions) {
216
+ component._commonOptions.forEach(opt => this.addOption(opt));
217
+ }
197
218
  const noLang = component.noLangOption;
198
- if (noLang) this.addOption(noLang);
219
+ // Skip noLang if its ID is already a common item (would be silently discarded as a duplicate)
220
+ const noLangIsCommon = noLang && component._commonOptions && component._commonOptions.some(o => o.id === noLang.id);
221
+ if (noLang && !noLangIsCommon) this.addOption(noLang);
199
222
  },
200
223
  // On startup, update any existing options with latest data from search
201
224
  onInitialize: async function() {
202
225
  const ids = Object.keys(this.options);
203
226
  const noLang = component.noLangOption;
204
- // Always make the no-lang option available for new selections
205
- if (noLang) this.addOption(noLang);
227
+ // Fetch and register common items (x-common group goes first)
228
+ const commonIds = component.commonIds;
229
+ if (commonIds.length > 0) {
230
+ this.addOptionGroup('x-common', { label: 'Common' });
231
+ const commonParams = new URLSearchParams({
232
+ q: '*',
233
+ filter_by: `id:[${commonIds.join(",")}]`,
234
+ per_page: commonIds.length,
235
+ });
236
+ const commonResults = await component.searchRequest(commonParams);
237
+ component._commonOptions = commonResults.map(r => ({...r, lang_family: 'x-common'}));
238
+ component._commonOptions.forEach(opt => this.addOption(opt));
239
+ }
240
+ // Add noLang option now (after common items) so we can check for overlap
241
+ const noLangIsCommon = noLang && component._commonOptions && component._commonOptions.some(o => o.id === noLang.id);
242
+ if (noLang && !noLangIsCommon) this.addOption(noLang);
206
243
  // In language mode, fetch families and register option groups
207
244
  if (component.isLanguageMode) {
208
245
  const families = await component.getLanguageFamilies();
@@ -212,8 +249,9 @@ class LucosSearchComponent extends HTMLSpanElement {
212
249
  });
213
250
  }
214
251
  if (ids.length < 1) return;
215
- // Fetch real options from Typesense, excluding the synthetic no-lang option
216
- const idsToFetch = noLang ? ids.filter(id => id !== noLang.id) : ids;
252
+ // Fetch real options from Typesense, excluding the synthetic no-lang option and common items
253
+ const excludeIds = new Set([...(noLang ? [noLang.id] : []), ...commonIds]);
254
+ const idsToFetch = ids.filter(id => !excludeIds.has(id));
217
255
  if (idsToFetch.length > 0) {
218
256
  const searchParams = new URLSearchParams({
219
257
  q: '*',
@@ -230,6 +268,12 @@ class LucosSearchComponent extends HTMLSpanElement {
230
268
  if (noLang && ids.includes(noLang.id)) {
231
269
  this.updateOption(noLang.id, noLang);
232
270
  }
271
+ // Update any pre-selected common items with fresh data
272
+ if (component._commonOptions) {
273
+ component._commonOptions.forEach(opt => {
274
+ if (ids.includes(opt.id)) this.updateOption(opt.id, opt);
275
+ });
276
+ }
233
277
  },
234
278
  onItemSelect: function (item) {
235
279
  // Tom-select prevents clicking on link in an item to work as normal, so force it here
@@ -276,6 +320,11 @@ class LucosSearchComponent extends HTMLSpanElement {
276
320
  if (!types) return false;
277
321
  return types.split(",").map(t => t.trim()).includes("Language");
278
322
  }
323
+ get commonIds() {
324
+ const common = this.getAttribute("data-common");
325
+ if (!common) return [];
326
+ return common.split(",").map(s => s.trim()).filter(Boolean);
327
+ }
279
328
  async getLanguageFamilies() {
280
329
  if (this._langFamilies) return this._langFamilies;
281
330
  const key = this.getAttribute("data-api-key");
@@ -309,7 +358,7 @@ class LucosSearchComponent extends HTMLSpanElement {
309
358
  }
310
359
  return this._langFamilies;
311
360
  }
312
- async searchRequest(searchParams) {
361
+ async searchRequest(searchParams, abortSignal = null) {
313
362
  const key = this.getAttribute("data-api-key");
314
363
  if (!key) throw new Error("No `data-api-key` attribute set on `lucos-search` component");
315
364
  searchParams.set('query_by', "pref_label,labels,description,lyrics");
@@ -320,13 +369,16 @@ class LucosSearchComponent extends HTMLSpanElement {
320
369
  searchParams.set('enable_highlight_v1', false);
321
370
  searchParams.set('highlight_start_tag', '<span class="highlight">')
322
371
  searchParams.set('highlight_end_tag', '</span>');
372
+ const timeoutSignal = AbortSignal.timeout(8000);
373
+ const signal = abortSignal ? AbortSignal.any([timeoutSignal, abortSignal]) : timeoutSignal;
323
374
  let response;
324
375
  try {
325
376
  response = await fetch("https://arachne.l42.eu/search?"+searchParams.toString(), {
326
377
  headers: { 'X-TYPESENSE-API-KEY': key },
327
- signal: AbortSignal.timeout(8000),
378
+ signal,
328
379
  });
329
380
  } catch(err) {
381
+ if (err.name === 'AbortError') throw err; // Pass through so caller can detect cancellation
330
382
  const userMessage = err.name === 'TimeoutError'
331
383
  ? 'Search timed out — please try again later.'
332
384
  : 'Search is currently unavailable — please try again later.';