@arbetsformedlingen/yrkesvaljaren 2.0.5

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 (146) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +369 -0
  3. package/dist/cjs/af-validation-message-BzY5H0xU.js +16 -0
  4. package/dist/cjs/core-selector.cjs.entry.js +476 -0
  5. package/dist/cjs/digi-service-job-selector.cjs.entry.js +1619 -0
  6. package/dist/cjs/fuse-BTLNKxuL.js +1631 -0
  7. package/dist/cjs/geo-valjaren.cjs.entry.js +14910 -0
  8. package/dist/cjs/index-BOuTGlEp.js +1817 -0
  9. package/dist/cjs/index.cjs.js +14 -0
  10. package/dist/cjs/job-selector-demo.cjs.entry.js +692 -0
  11. package/dist/cjs/loader.cjs.js +12 -0
  12. package/dist/cjs/neobrutal-search.cjs.entry.js +171 -0
  13. package/dist/cjs/search.engine-D0cMelRi.js +489 -0
  14. package/dist/cjs/taxonomy-registry-DVmuDUpM.js +435 -0
  15. package/dist/cjs/types-CtHBl6Ra.js +14 -0
  16. package/dist/cjs/yrkesvaljaren.cjs.js +24 -0
  17. package/dist/collection/collection-manifest.json +15 -0
  18. package/dist/collection/components/core-selector/core-selector.css +803 -0
  19. package/dist/collection/components/core-selector/core-selector.js +761 -0
  20. package/dist/collection/components/core-selector/types.js +1 -0
  21. package/dist/collection/components/design-compat/af-icon.js +13 -0
  22. package/dist/collection/components/design-compat/af-label.js +2 -0
  23. package/dist/collection/components/design-compat/af-tag.js +6 -0
  24. package/dist/collection/components/design-compat/af-validation-message.js +11 -0
  25. package/dist/collection/components/design-compat/index.js +4 -0
  26. package/dist/collection/components/geovaljaren/geovaljaren.css +21 -0
  27. package/dist/collection/components/geovaljaren/geovaljaren.js +304 -0
  28. package/dist/collection/components/geovaljaren/logic/adapter.utils.js +20 -0
  29. package/dist/collection/components/geovaljaren/logic/data.utils.js +12 -0
  30. package/dist/collection/components/geovaljaren/logic/history-service.js +25 -0
  31. package/dist/collection/components/geovaljaren/logic/search.engine.js +28 -0
  32. package/dist/collection/components/job-selector/constants.js +26 -0
  33. package/dist/collection/components/job-selector/history-service.js +41 -0
  34. package/dist/collection/components/job-selector/job-selector-controller.js +835 -0
  35. package/dist/collection/components/job-selector/job-selector.css +318 -0
  36. package/dist/collection/components/job-selector/job-selector.js +1378 -0
  37. package/dist/collection/components/job-selector/keyboard-handlers.js +84 -0
  38. package/dist/collection/components/job-selector/logic/adapter.utils.js +75 -0
  39. package/dist/collection/components/job-selector/logic/label-formatter.js +118 -0
  40. package/dist/collection/components/job-selector/logic/search.engine.js +50 -0
  41. package/dist/collection/components/job-selector/search-logic.js +35 -0
  42. package/dist/collection/components/job-selector/search.utils.js +224 -0
  43. package/dist/collection/components/job-selector/suggestions-list.js +31 -0
  44. package/dist/collection/components/job-selector/taxonomy-registry.js +60 -0
  45. package/dist/collection/components/job-selector/test-data.mock.js +33 -0
  46. package/dist/collection/components/job-selector/types.js +10 -0
  47. package/dist/collection/components/job-selector/validation-message.js +24 -0
  48. package/dist/collection/demo-data.js +20 -0
  49. package/dist/collection/index.js +7 -0
  50. package/dist/collection/verkstad/verkstad.js +407 -0
  51. package/dist/components/core-selector.d.ts +11 -0
  52. package/dist/components/core-selector.js +1 -0
  53. package/dist/components/core-selector2.js +1 -0
  54. package/dist/components/digi-service-job-selector.d.ts +11 -0
  55. package/dist/components/digi-service-job-selector.js +1 -0
  56. package/dist/components/fuse.js +1 -0
  57. package/dist/components/geo-valjaren.d.ts +11 -0
  58. package/dist/components/geo-valjaren.js +1 -0
  59. package/dist/components/index.d.ts +35 -0
  60. package/dist/components/index.js +1 -0
  61. package/dist/components/job-selector-demo.d.ts +11 -0
  62. package/dist/components/job-selector-demo.js +1 -0
  63. package/dist/components/job-selector.js +1 -0
  64. package/dist/components/neobrutal-search.d.ts +11 -0
  65. package/dist/components/neobrutal-search.js +1 -0
  66. package/dist/components/search.engine.js +1 -0
  67. package/dist/components/types.js +1 -0
  68. package/dist/esm/af-validation-message-F8NIH2rC.js +14 -0
  69. package/dist/esm/core-selector.entry.js +474 -0
  70. package/dist/esm/digi-service-job-selector.entry.js +1617 -0
  71. package/dist/esm/fuse-BPnhMTul.js +1629 -0
  72. package/dist/esm/geo-valjaren.entry.js +14908 -0
  73. package/dist/esm/index-CGXZllj2.js +1806 -0
  74. package/dist/esm/index.js +10 -0
  75. package/dist/esm/job-selector-demo.entry.js +690 -0
  76. package/dist/esm/loader.js +10 -0
  77. package/dist/esm/neobrutal-search.entry.js +169 -0
  78. package/dist/esm/search.engine-nZ7AcXba.js +479 -0
  79. package/dist/esm/taxonomy-registry-Dw0VR9Aw.js +430 -0
  80. package/dist/esm/types-DHUftoZJ.js +12 -0
  81. package/dist/esm/yrkesvaljaren.js +20 -0
  82. package/dist/index.cjs.js +1 -0
  83. package/dist/index.js +1 -0
  84. package/dist/types/components/core-selector/core-selector.d.ts +46 -0
  85. package/dist/types/components/core-selector/types.d.ts +1 -0
  86. package/dist/types/components/design-compat/af-icon.d.ts +7 -0
  87. package/dist/types/components/design-compat/af-label.d.ts +8 -0
  88. package/dist/types/components/design-compat/af-tag.d.ts +8 -0
  89. package/dist/types/components/design-compat/af-validation-message.d.ts +9 -0
  90. package/dist/types/components/design-compat/index.d.ts +5 -0
  91. package/dist/types/components/geovaljaren/geovaljaren.d.ts +24 -0
  92. package/dist/types/components/geovaljaren/logic/adapter.utils.d.ts +7 -0
  93. package/dist/types/components/geovaljaren/logic/data.utils.d.ts +7 -0
  94. package/dist/types/components/geovaljaren/logic/history-service.d.ts +6 -0
  95. package/dist/types/components/geovaljaren/logic/search.engine.d.ts +7 -0
  96. package/dist/types/components/job-selector/ambient.d.ts +4 -0
  97. package/dist/types/components/job-selector/constants.d.ts +13 -0
  98. package/dist/types/components/job-selector/history-service.d.ts +6 -0
  99. package/dist/types/components/job-selector/job-selector-controller.d.ts +82 -0
  100. package/dist/types/components/job-selector/job-selector.d.ts +140 -0
  101. package/dist/types/components/job-selector/keyboard-handlers.d.ts +13 -0
  102. package/dist/types/components/job-selector/logic/adapter.utils.d.ts +12 -0
  103. package/dist/types/components/job-selector/logic/label-formatter.d.ts +4 -0
  104. package/dist/types/components/job-selector/logic/search.engine.d.ts +21 -0
  105. package/dist/types/components/job-selector/search-logic.d.ts +17 -0
  106. package/dist/types/components/job-selector/search.utils.d.ts +10 -0
  107. package/dist/types/components/job-selector/suggestions-list.d.ts +13 -0
  108. package/dist/types/components/job-selector/taxonomy-registry.d.ts +20 -0
  109. package/dist/types/components/job-selector/test-data.mock.d.ts +7 -0
  110. package/dist/types/components/job-selector/types.d.ts +107 -0
  111. package/dist/types/components/job-selector/validation-message.d.ts +8 -0
  112. package/dist/types/components/job-selector-demo/job-selector-demo.d.ts +55 -0
  113. package/dist/types/components/neobrutal-search/neobrutal-search.d.ts +18 -0
  114. package/dist/types/components.d.ts +425 -0
  115. package/dist/types/demo-data.d.ts +16 -0
  116. package/dist/types/index.d.ts +5 -0
  117. package/dist/types/stencil-public-runtime.d.ts +1873 -0
  118. package/dist/types/verkstad/verkstad.d.ts +1 -0
  119. package/dist/yrkesvaljaren/fuse-B0eHOxow.js.map +1 -0
  120. package/dist/yrkesvaljaren/index-Cy4BCo5s.js.map +1 -0
  121. package/dist/yrkesvaljaren/index-DfJXTQqL.js.map +1 -0
  122. package/dist/yrkesvaljaren/index.esm.js +1 -0
  123. package/dist/yrkesvaljaren/index.esm.js.map +1 -0
  124. package/dist/yrkesvaljaren/p-16636162.entry.js +1 -0
  125. package/dist/yrkesvaljaren/p-273cf6df.entry.js +1 -0
  126. package/dist/yrkesvaljaren/p-9e48afa4.entry.js +1 -0
  127. package/dist/yrkesvaljaren/p-BPnhMTul.js +1 -0
  128. package/dist/yrkesvaljaren/p-CGXZllj2.js +3 -0
  129. package/dist/yrkesvaljaren/p-DHUftoZJ.js +1 -0
  130. package/dist/yrkesvaljaren/p-DYFAzvqX.js +1 -0
  131. package/dist/yrkesvaljaren/p-Dw0VR9Aw.js +1 -0
  132. package/dist/yrkesvaljaren/p-aef7b5ae.entry.js +1 -0
  133. package/dist/yrkesvaljaren/p-b0f16abd.entry.js +1 -0
  134. package/dist/yrkesvaljaren/p-uBrs3sSs.js +1 -0
  135. package/dist/yrkesvaljaren/search.engine-DPT6roRm.js.map +1 -0
  136. package/dist/yrkesvaljaren/taxonomy-registry-s5khf6s4.js.map +1 -0
  137. package/dist/yrkesvaljaren/types-DHUftoZJ.js.map +1 -0
  138. package/dist/yrkesvaljaren/yrkesvaljaren.css +1 -0
  139. package/dist/yrkesvaljaren/yrkesvaljaren.esm.js +1 -0
  140. package/dist/yrkesvaljaren/yrkesvaljaren.esm.js.map +1 -0
  141. package/loader/cdn.js +1 -0
  142. package/loader/index.cjs.js +1 -0
  143. package/loader/index.d.ts +24 -0
  144. package/loader/index.es2017.js +1 -0
  145. package/loader/index.js +2 -0
  146. package/package.json +126 -0
@@ -0,0 +1,84 @@
1
+ import { shouldShowManualInputOption } from "./search-logic";
2
+ import log from "loglevel";
3
+ function navigateSuggestions(direction, context) {
4
+ const { state, activeIndex, listRef, listId, requireSelection, onNavigate } = context;
5
+ log.debug('[KeyboardHandlers] navigateSuggestions - Direction:', direction);
6
+ if (state.status !== 'searching' && state.status !== 'multiSelect') {
7
+ return;
8
+ }
9
+ const suggestions = state.suggestions;
10
+ const inputValue = state.inputValue;
11
+ const showManualInputOption = shouldShowManualInputOption(suggestions, inputValue, requireSelection);
12
+ // Total antal valbara alternativ (inkl manuell input om den visas)
13
+ const totalOptions = suggestions.length + (showManualInputOption ? 1 : 0);
14
+ if (totalOptions === 0) {
15
+ return;
16
+ }
17
+ const newIndex = direction === 'down'
18
+ ? (activeIndex + 1) % totalOptions
19
+ : (activeIndex - 1 + totalOptions) % totalOptions;
20
+ onNavigate(newIndex);
21
+ // Scrolla till rätt element
22
+ // Vi kan inte göra this.listRef.querySelector i denna funktion utan referens,
23
+ // men vi har listRef i context.
24
+ if (listRef) {
25
+ const optionId = newIndex === suggestions.length
26
+ ? `${listId}-option-manual-input`
27
+ : `${listId}-option-${newIndex}`;
28
+ const activeElement = listRef.querySelector(`#${optionId}`);
29
+ activeElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
30
+ }
31
+ }
32
+ export function handleKeyboardNavigation(event, context) {
33
+ const { state, activeIndex, requireSelection, onSelect, onClearSuggestions, onEnterWithoutSelection } = context;
34
+ log.debug('[KeyboardHandlers] handleKeyDown - Key pressed:', event.key);
35
+ if (state.status !== 'searching' && state.status !== 'multiSelect') {
36
+ return;
37
+ }
38
+ const suggestions = state.suggestions;
39
+ const inputValue = state.inputValue;
40
+ const trimmedInput = inputValue.trim();
41
+ const showManualInputOption = shouldShowManualInputOption(suggestions, inputValue, requireSelection);
42
+ const totalOptions = suggestions.length + (showManualInputOption ? 1 : 0);
43
+ switch (event.key) {
44
+ case 'ArrowDown':
45
+ if (totalOptions > 0) {
46
+ event.preventDefault();
47
+ navigateSuggestions('down', context);
48
+ }
49
+ break;
50
+ case 'ArrowUp':
51
+ if (totalOptions > 0) {
52
+ event.preventDefault();
53
+ navigateSuggestions('up', context);
54
+ }
55
+ break;
56
+ case 'Enter':
57
+ event.preventDefault();
58
+ // Kolla om manuell input-alternativet är markerat
59
+ if (showManualInputOption && activeIndex === suggestions.length) {
60
+ // Användaren har navigerat till manuell input-alternativet
61
+ const manualInputItem = {
62
+ id: '',
63
+ type: 'ManualInput',
64
+ rawLabel: trimmedInput,
65
+ displayLabel: trimmedInput,
66
+ highlightedLabel: trimmedInput,
67
+ score: 1.0,
68
+ preferred_label: trimmedInput
69
+ };
70
+ onSelect(manualInputItem);
71
+ }
72
+ else if (activeIndex > -1 && activeIndex < suggestions.length) {
73
+ // Användaren har valt ett förslag med piltangenter
74
+ onSelect(suggestions[activeIndex]);
75
+ }
76
+ else if (!requireSelection && trimmedInput !== '') {
77
+ onEnterWithoutSelection(trimmedInput);
78
+ }
79
+ break;
80
+ case 'Escape':
81
+ onClearSuggestions();
82
+ break;
83
+ }
84
+ }
@@ -0,0 +1,75 @@
1
+ import { LabelFormatter } from "./label-formatter";
2
+ import { segmentsToHtml } from "../search.utils";
3
+ export const TaxonomyAdapter = {
4
+ /** Maps a raw TaxonomyItem to a GenericItem for the CoreSelector */
5
+ toGenericItem(item) {
6
+ return {
7
+ id: item.id,
8
+ label: item.preferred_label || '',
9
+ description: item.occupation_name_preferred_label,
10
+ metadata: {
11
+ original: item,
12
+ type: item.type,
13
+ ssyk_level_4_id: item.ssyk_level_4_id,
14
+ weight: item.weight
15
+ }
16
+ };
17
+ },
18
+ /** Maps a SearchResult (from our engine) to a GenericItem */
19
+ searchResultToGenericItem(result, query) {
20
+ const segments = LabelFormatter.format(result.preferred_label || '', query, result.occupation_name_preferred_label);
21
+ return {
22
+ id: result.id,
23
+ label: result.preferred_label || '',
24
+ displaySegments: segments,
25
+ highlightedLabel: segmentsToHtml(segments),
26
+ metadata: {
27
+ original: result,
28
+ ssyk_level_4_id: result.ssyk_level_4_id,
29
+ weight: result.weight
30
+ }
31
+ };
32
+ },
33
+ /** Maps a GenericItem back to our internal SearchResult format */
34
+ fromGenericItem(item) {
35
+ return {
36
+ id: item.id,
37
+ preferred_label: item.label,
38
+ weight: item.metadata?.weight,
39
+ ...item.metadata?.original
40
+ };
41
+ },
42
+ /** Maps an internal item to the public event format */
43
+ toPublicSelection(item) {
44
+ const id = item.id || '';
45
+ const preferredLabel = item.preferred_label || item.rawLabel || '';
46
+ if (!id) {
47
+ return {
48
+ type: 'manual-input',
49
+ id: null,
50
+ preferred_label: preferredLabel,
51
+ };
52
+ }
53
+ if (item.type !== 'job-title' && item.type !== 'occupation-name') {
54
+ throw new Error(`Ogiltig intern yrkestyp för ${id}: ${item.type}`);
55
+ }
56
+ const selection = {
57
+ type: item.type,
58
+ id,
59
+ preferred_label: preferredLabel,
60
+ };
61
+ if (item.ssyk_code_2012) {
62
+ selection.ssyk_code_2012 = item.ssyk_code_2012;
63
+ }
64
+ const occupationId = item.occupation_name_id || item.occupationId;
65
+ const occupationLabel = item.occupation_name_preferred_label || item.occupationLabel;
66
+ if (item.type === 'job-title' && occupationId && occupationLabel) {
67
+ selection.related = {
68
+ type: 'occupation-name',
69
+ id: occupationId,
70
+ preferred_label: occupationLabel,
71
+ };
72
+ }
73
+ return selection;
74
+ }
75
+ };
@@ -0,0 +1,118 @@
1
+ export const LabelFormatter = {
2
+ format: (label, query, occupationLabel) => {
3
+ // 1. Highlight the main label based on query
4
+ const labelSegments = highlightText(label, query);
5
+ // 2. Append occupation label if it exists (the "metadata" part)
6
+ if (occupationLabel) {
7
+ return [
8
+ ...labelSegments,
9
+ { text: ' ', isMatch: false },
10
+ { text: `(${occupationLabel})`, isMetadata: true }
11
+ ];
12
+ }
13
+ return labelSegments;
14
+ }
15
+ };
16
+ function calculateEditDistance(a, b) {
17
+ const m = a.length;
18
+ const n = b.length;
19
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
20
+ for (let i = 0; i <= m; i++)
21
+ dp[i][0] = i;
22
+ for (let j = 0; j <= n; j++)
23
+ dp[0][j] = j;
24
+ for (let i = 1; i <= m; i++) {
25
+ for (let j = 1; j <= n; j++) {
26
+ dp[i][j] = a[i - 1] === b[j - 1]
27
+ ? dp[i - 1][j - 1]
28
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
29
+ }
30
+ }
31
+ return dp[m][n];
32
+ }
33
+ function findBestFuzzySubstring(text, qToken) {
34
+ const lowerText = text.toLowerCase();
35
+ const targetLen = qToken.length;
36
+ let bestDist = 99;
37
+ let bestStart = -1;
38
+ let bestLen = 0;
39
+ const minLen = Math.max(3, targetLen - 2);
40
+ const maxLen = Math.min(lowerText.length, targetLen + 2);
41
+ for (let start = 0; start <= lowerText.length - minLen; start++) {
42
+ for (let len = minLen; len <= maxLen && start + len <= lowerText.length; len++) {
43
+ const sub = lowerText.slice(start, start + len);
44
+ const dist = calculateEditDistance(qToken, sub);
45
+ if (dist < bestDist) {
46
+ bestDist = dist;
47
+ bestStart = start;
48
+ bestLen = len;
49
+ }
50
+ }
51
+ }
52
+ const maxAllowedDist = Math.max(2, Math.floor(targetLen * 0.25));
53
+ if (bestDist <= maxAllowedDist && bestStart !== -1) {
54
+ return { start: bestStart, length: bestLen };
55
+ }
56
+ return null;
57
+ }
58
+ /**
59
+ * Highlights matches in text based on query.
60
+ * Supports multi-word queries.
61
+ */
62
+ function highlightText(text, query) {
63
+ if (!text)
64
+ return [];
65
+ if (!query || query.trim().length === 0) {
66
+ return [{ text, isMatch: false }];
67
+ }
68
+ const lowerText = text.toLowerCase();
69
+ const tokens = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
70
+ // Set of indices in the original text that should be highlighted
71
+ const highlightedIndices = new Set();
72
+ tokens.forEach(token => {
73
+ let startIndex = 0;
74
+ while ((startIndex = lowerText.indexOf(token, startIndex)) !== -1) {
75
+ for (let i = 0; i < token.length; i++) {
76
+ highlightedIndices.add(startIndex + i);
77
+ }
78
+ startIndex += token.length;
79
+ }
80
+ });
81
+ if (highlightedIndices.size === 0) {
82
+ // Fuzzy highlight fallback: Hitta bästa matchande delsträng i sammansatta ord eller felstavningar
83
+ tokens.forEach(qToken => {
84
+ const match = findBestFuzzySubstring(text, qToken);
85
+ if (match) {
86
+ for (let i = 0; i < match.length; i++) {
87
+ highlightedIndices.add(match.start + i);
88
+ }
89
+ }
90
+ });
91
+ }
92
+ if (highlightedIndices.size === 0) {
93
+ return [{ text, isMatch: false }];
94
+ }
95
+ const segments = [];
96
+ let currentSegmentText = '';
97
+ let currentIsMatch = highlightedIndices.has(0);
98
+ for (let i = 0; i < text.length; i++) {
99
+ const isMatch = highlightedIndices.has(i);
100
+ if (isMatch !== currentIsMatch) {
101
+ // Push previous segment
102
+ if (currentSegmentText.length > 0) {
103
+ segments.push({ text: currentSegmentText, isMatch: currentIsMatch });
104
+ }
105
+ // Start new segment
106
+ currentSegmentText = text[i];
107
+ currentIsMatch = isMatch;
108
+ }
109
+ else {
110
+ currentSegmentText += text[i];
111
+ }
112
+ }
113
+ // Push the last segment
114
+ if (currentSegmentText.length > 0) {
115
+ segments.push({ text: currentSegmentText, isMatch: currentIsMatch });
116
+ }
117
+ return segments;
118
+ }
@@ -0,0 +1,50 @@
1
+ import Fuse from "fuse.js";
2
+ import { searchConcepts } from "../search.utils";
3
+ import { FUSE_OPTIONS } from "../constants";
4
+ import { TaxonomyAdapter } from "./adapter.utils";
5
+ export class TaxonomySearchEngine {
6
+ fuseInstance = null;
7
+ items = [];
8
+ constructor(data) {
9
+ this.items = data;
10
+ this.fuseInstance = new Fuse(data, FUSE_OPTIONS);
11
+ }
12
+ /**
13
+ * Public API: Performs a search and returns clean, official domain objects.
14
+ * This is what external consumers (headless) should use.
15
+ */
16
+ search(query, maxResults = 10) {
17
+ if (!this.fuseInstance)
18
+ return [];
19
+ const results = searchConcepts(query, this.items, this.fuseInstance, maxResults);
20
+ // Map directly to public selection objects
21
+ return results.map(result => TaxonomyAdapter.toPublicSelection(result));
22
+ }
23
+ /**
24
+ * Internal API: Performs a search and returns GenericItems for the CoreSelector UI ("Bucky").
25
+ * Includes highlighting, grouping and internal metadata.
26
+ */
27
+ searchAsGeneric(query, maxResults = 10) {
28
+ if (!this.fuseInstance)
29
+ return [];
30
+ // Use the existing robust search function
31
+ const results = searchConcepts(query, this.items, this.fuseInstance, maxResults);
32
+ // Adapt SearchResult -> GenericItem
33
+ return results.map(result => ({
34
+ id: result.id,
35
+ label: result.displayLabel, // Use the formatted label (e.g. "Nurse (Healthcare)")
36
+ description: result.occupationLabel,
37
+ group: result.type === 'job-title' ? 'Yrke' : 'Yrkesgrupp',
38
+ metadata: {
39
+ originalResult: result, // Keep full original result for consumers
40
+ highlightedLabel: result.highlightedLabel,
41
+ score: result.score,
42
+ weight: result.weight
43
+ }
44
+ }));
45
+ }
46
+ updateData(newData) {
47
+ this.items = newData;
48
+ this.fuseInstance = new Fuse(newData, FUSE_OPTIONS);
49
+ }
50
+ }
@@ -0,0 +1,35 @@
1
+ const GOOD_MATCH_MIN_SCORE = 0.9;
2
+ /**
3
+ * Determines whether the "Manual Input" option should be shown in the suggestions list.
4
+ *
5
+ * Logic:
6
+ * 1. If requireSelection is true, never show manual input option.
7
+ * 2. If input is empty/whitespace, never show manual input option.
8
+ * 3. If there's an exact match (case-insensitive) in suggestions, don't show (avoid duplicates).
9
+ * 4. If the best suggestion has a "good" direct score, don't show.
10
+ * 5. Otherwise, show the manual input option.
11
+ *
12
+ * @param suggestions - Array of search results from the taxonomy search
13
+ * @param inputValue - Current user input value
14
+ * @param requireSelection - Whether selection from taxonomy is required
15
+ * @returns true if manual input option should be displayed
16
+ */
17
+ export function shouldShowManualInputOption(suggestions, inputValue, requireSelection) {
18
+ // Rule 1: If selection is required, never allow manual input
19
+ if (requireSelection)
20
+ return false;
21
+ // Rule 2: Empty input = no manual input option
22
+ const trimmedInput = inputValue.trim();
23
+ if (trimmedInput === '')
24
+ return false;
25
+ // Rule 3: Check for exact match (avoid duplicate entries)
26
+ const lowerInput = trimmedInput.toLowerCase();
27
+ const exactMatch = suggestions.some((s) => s.rawLabel.toLowerCase() === lowerInput ||
28
+ s.displayLabel.toLowerCase() === lowerInput);
29
+ if (exactMatch)
30
+ return false;
31
+ // Rule 4: Check "registry quality" - only show manual input for poor matches.
32
+ const bestScore = suggestions.length > 0 ? (suggestions[0].score ?? 0) : 0;
33
+ const hasGoodMatch = bestScore >= GOOD_MATCH_MIN_SCORE;
34
+ return !hasGoodMatch;
35
+ }
@@ -0,0 +1,224 @@
1
+ import { MIN_INPUT_LENGTH } from "./constants";
2
+ import log from "loglevel";
3
+ import { LabelFormatter } from "./logic/label-formatter";
4
+ const MAX_WEAK_FUZZY_RESULTS = 3;
5
+ const MIN_FUZZY_SCORE = 0.4;
6
+ const MAX_FUZZY_SCORE = 0.84;
7
+ if (process.env.NODE_ENV === 'test') {
8
+ log.setLevel(log.levels.SILENT);
9
+ }
10
+ else {
11
+ log.setLevel(log.levels.DEBUG); // Set log level to DEBUG for development/testing
12
+ }
13
+ export const normalizeForComparison = (text) => {
14
+ if (!text) {
15
+ return '';
16
+ }
17
+ const normalized = text.toLowerCase().trim();
18
+ return normalized;
19
+ };
20
+ export function formatDisplayLabel(item) {
21
+ if (item.occupation_name_preferred_label) {
22
+ return `${item.preferred_label || ''} (${item.occupation_name_preferred_label})`;
23
+ }
24
+ return item.preferred_label || '';
25
+ }
26
+ ;
27
+ export function getLabel(item) {
28
+ return item.preferred_label || '';
29
+ }
30
+ export function sortResultsStrict(results) {
31
+ return results.sort((a, b) => {
32
+ const scoreA = a.score ?? 0;
33
+ const scoreB = b.score ?? 0;
34
+ if (scoreA !== scoreB) {
35
+ return scoreB - scoreA;
36
+ }
37
+ const weightA = a.weight ?? 0;
38
+ const weightB = b.weight ?? 0;
39
+ if (weightA !== weightB) {
40
+ // Buggen är fixad här
41
+ return weightB - weightA;
42
+ }
43
+ return a.displayLabel.localeCompare(b.displayLabel, 'sv');
44
+ });
45
+ }
46
+ export function segmentsToHtml(segments) {
47
+ return segments.map(seg => {
48
+ if (seg.isMatch) {
49
+ return `<strong>${seg.text}</strong>`;
50
+ }
51
+ if (seg.isMetadata) {
52
+ return `<span class="occupation-variant-label">${seg.text}</span>`;
53
+ }
54
+ return seg.text;
55
+ }).join('');
56
+ }
57
+ // Keep export for controller compatibility (it was used there)
58
+ // But implement using new logic or just re-export if we want to keep it simple?
59
+ // The controller passes HTML to it.
60
+ // Actually, `styleParentheticalText` took a string (HTML) and did regex replacement.
61
+ // If the controller uses it on `item.displayLabel` (which is plain text usually, or mapped),
62
+ // the controller does: `const historyItem = { ...item, highlightedLabel: styleParentheticalText(item.displayLabel) };`
63
+ // `displayLabel` for "Frontend (System)" is "Frontend (System)".
64
+ // If we run that through `segmentsToHtml(LabelFormatter.format(label, ''))`, we get the same result!
65
+ // So let's re-implement `styleParentheticalText` using `LabelFormatter`.
66
+ export const styleParentheticalText = (text) => {
67
+ if (!text)
68
+ return '';
69
+ return text.replace(/ (\([^)]+\))/g, ' <span class="occupation-variant-label">$1</span>');
70
+ };
71
+ function getDirectScore(normalizedLabel, normalizedQuery, searchTokens) {
72
+ if (normalizedLabel === normalizedQuery) {
73
+ return 1.0;
74
+ }
75
+ if (searchTokens.length > 1) {
76
+ return searchTokens.every(token => normalizedLabel.includes(token)) ? 0.98 : null;
77
+ }
78
+ if (normalizedLabel.startsWith(normalizedQuery)) {
79
+ return 0.99;
80
+ }
81
+ if (normalizedLabel.includes(normalizedQuery)) {
82
+ return 0.95;
83
+ }
84
+ return null;
85
+ }
86
+ function calculateEditDistance(a, b) {
87
+ const m = a.length;
88
+ const n = b.length;
89
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
90
+ for (let i = 0; i <= m; i++)
91
+ dp[i][0] = i;
92
+ for (let j = 0; j <= n; j++)
93
+ dp[0][j] = j;
94
+ for (let i = 1; i <= m; i++) {
95
+ for (let j = 1; j <= n; j++) {
96
+ dp[i][j] = a[i - 1] === b[j - 1]
97
+ ? dp[i - 1][j - 1]
98
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
99
+ }
100
+ }
101
+ return dp[m][n];
102
+ }
103
+ function findBestFuzzySubstring(text, qToken) {
104
+ const lowerText = text.toLowerCase();
105
+ const targetLen = qToken.length;
106
+ let bestDist = 99;
107
+ const minLen = Math.max(3, targetLen - 2);
108
+ const maxLen = Math.min(lowerText.length, targetLen + 2);
109
+ for (let start = 0; start <= lowerText.length - minLen; start++) {
110
+ for (let len = minLen; len <= maxLen && start + len <= lowerText.length; len++) {
111
+ const sub = lowerText.slice(start, start + len);
112
+ const dist = calculateEditDistance(qToken, sub);
113
+ if (dist < bestDist) {
114
+ bestDist = dist;
115
+ }
116
+ }
117
+ }
118
+ const maxAllowedDist = Math.max(2, Math.floor(targetLen * 0.25));
119
+ return bestDist <= maxAllowedDist ? bestDist : null;
120
+ }
121
+ function isSingleWordOrCompoundRoot(query, label) {
122
+ const normQ = query.toLowerCase().trim();
123
+ const normL = label.toLowerCase().trim();
124
+ if (normL.includes(' ')) {
125
+ const dist = calculateEditDistance(normQ, normL);
126
+ return dist <= Math.max(2, Math.floor(normQ.length * 0.25)) && Math.abs(normQ.length - normL.length) <= 2;
127
+ }
128
+ const subDist = findBestFuzzySubstring(normL, normQ);
129
+ return subDist !== null;
130
+ }
131
+ function createSearchResult(item, query, score) {
132
+ const displayLabel = formatDisplayLabel(item);
133
+ const displaySegments = LabelFormatter.format(item.preferred_label || '', query, item.occupation_name_preferred_label);
134
+ return {
135
+ id: item.id,
136
+ type: item.type,
137
+ preferred_label: item.preferred_label || '',
138
+ rawLabel: item.preferred_label || '',
139
+ displayLabel,
140
+ highlightedLabel: segmentsToHtml(displaySegments),
141
+ displaySegments,
142
+ weight: item.weight ?? 0,
143
+ score,
144
+ occupationId: item.occupation_name_id,
145
+ occupationLabel: item.occupation_name_preferred_label,
146
+ ssyk_level_4_id: item.ssyk_level_4_id,
147
+ ssyk_code_2012: item.ssyk_code_2012,
148
+ };
149
+ }
150
+ export function searchConcepts(query, items, fuseInstance, maxResults) {
151
+ if (!fuseInstance) {
152
+ return [];
153
+ }
154
+ if (!items || !Array.isArray(items)) {
155
+ log.warn('[searchConcepts] Ogiltig items array.');
156
+ return [];
157
+ }
158
+ if (!query || query.length < MIN_INPUT_LENGTH) {
159
+ return [];
160
+ }
161
+ const normalizedQuery = normalizeForComparison(query);
162
+ const searchTokens = normalizedQuery.split(' ').filter(t => t.length > 0);
163
+ const directResults = [];
164
+ const processedIds = new Set();
165
+ for (const item of items) {
166
+ const normalizedLabel = normalizeForComparison(item.preferred_label);
167
+ const score = getDirectScore(normalizedLabel, normalizedQuery, searchTokens);
168
+ if (score !== null) {
169
+ directResults.push(createSearchResult(item, query, score));
170
+ processedIds.add(item.id);
171
+ }
172
+ }
173
+ if (directResults.length > 0) {
174
+ return sortResultsStrict(directResults).slice(0, maxResults);
175
+ }
176
+ // Fuzzy är ren fallback när inga direkta träffar finns.
177
+ if (fuseInstance) {
178
+ const fuseSearchOutput = fuseInstance.search(normalizedQuery);
179
+ if (fuseSearchOutput.length === 0) {
180
+ return [];
181
+ }
182
+ const bestRawScore = fuseSearchOutput[0].score ?? 1.0;
183
+ const candidateResults = [];
184
+ let hasStrongRootMatch = false;
185
+ for (const fr of fuseSearchOutput) {
186
+ if (processedIds.has(fr.item.id)) {
187
+ continue;
188
+ }
189
+ const rawScore = fr.score ?? 1.0;
190
+ const baseFuzzyScore = 1 - rawScore;
191
+ if (baseFuzzyScore < MIN_FUZZY_SCORE) {
192
+ continue;
193
+ }
194
+ // Strikt gap-filter: Tillåt aldrig träffar som har mer än 0.12 sämre råpoäng än bästa träffen
195
+ if (rawScore > bestRawScore + 0.12) {
196
+ continue;
197
+ }
198
+ const label = normalizeForComparison(fr.item.preferred_label);
199
+ const weight = fr.item.weight ?? 0;
200
+ // Hitta om etiketten innehåller en rotterm eller sammansatt del som matchar sökningen
201
+ const isRootMatch = isSingleWordOrCompoundRoot(normalizedQuery, label);
202
+ if (isRootMatch && rawScore <= 0.25) {
203
+ hasStrongRootMatch = true;
204
+ }
205
+ let score = baseFuzzyScore * 0.70 + (weight * 0.10);
206
+ if (isRootMatch) {
207
+ score += 0.12;
208
+ }
209
+ const boundedScore = Math.min(MAX_FUZZY_SCORE, Math.max(MIN_FUZZY_SCORE, score));
210
+ candidateResults.push(createSearchResult(fr.item, query, boundedScore));
211
+ processedIds.add(fr.item.id);
212
+ }
213
+ const sortedFuzzy = sortResultsStrict(candidateResults);
214
+ if (sortedFuzzy.length === 0) {
215
+ return [];
216
+ }
217
+ // Dynamiskt maxantal vid fuzzy-sökningar:
218
+ // - Stark rotmatchning (t.ex. injenjör, barlnmorska): Visa upp till 5 relevanta förslag så att fritextfältet syns utan att skrolla.
219
+ // - Svag/osäker matchning (t.ex. böla fölkja, pensionär): Visa MAX 3 träffar.
220
+ const limit = hasStrongRootMatch ? Math.min(5, maxResults) : Math.min(MAX_WEAK_FUZZY_RESULTS, maxResults);
221
+ return sortedFuzzy.slice(0, limit);
222
+ }
223
+ return [];
224
+ }
@@ -0,0 +1,31 @@
1
+ import { h } from "@stencil/core";
2
+ import { shouldShowManualInputOption } from "./search-logic";
3
+ import log from "loglevel";
4
+ export const SuggestionsList = (props) => {
5
+ const { suggestions, inputValue, activeIndex, listId, requireSelection, onSelect, onMouseEnter, setListRef } = props;
6
+ const showManualInput = shouldShowManualInputOption(suggestions, inputValue, requireSelection);
7
+ log.debug('[SuggestionsList] Rendering list. Count:', suggestions.length, 'Show manual input:', showManualInput);
8
+ const renderSuggestion = (suggestion, index) => {
9
+ const isActive = index === activeIndex;
10
+ return (h("div", { role: "option", id: `${listId}-option-${index}`, "aria-selected": isActive ? 'true' : 'false', class: { 'suggestion-item': true, 'active-suggestion': isActive }, onClick: () => onSelect(suggestion), onMouseEnter: () => onMouseEnter(index) }, suggestion.displaySegments ? (h("span", null, suggestion.displaySegments.map((segment, segmentIndex) => (h("span", { key: segmentIndex, class: {
11
+ 'match-highlight': !!segment.isMatch,
12
+ 'metadata-dimmed': !!segment.isMetadata
13
+ } }, segment.text))))) : (h("span", null, suggestion.displayLabel || suggestion.rawLabel))));
14
+ };
15
+ return (h("div", { class: "suggestions-box", ref: el => setListRef(el), id: listId, role: "listbox", onMouseDown: e => e.preventDefault() }, suggestions.map((suggestion, index) => renderSuggestion(suggestion, index)), showManualInput && (h("div", { role: "option", id: `${listId}-option-manual-input`, "aria-selected": activeIndex === suggestions.length ? 'true' : 'false', class: {
16
+ 'suggestion-item': true,
17
+ 'suggestion-item-manual-input': true,
18
+ 'active-suggestion': activeIndex === suggestions.length
19
+ }, onClick: () => {
20
+ const manualInputItem = {
21
+ id: '',
22
+ type: 'ManualInput',
23
+ rawLabel: inputValue.trim(),
24
+ displayLabel: inputValue.trim(),
25
+ highlightedLabel: inputValue.trim(),
26
+ score: 1.0,
27
+ preferred_label: inputValue.trim()
28
+ };
29
+ onSelect(manualInputItem);
30
+ }, onMouseEnter: () => onMouseEnter(suggestions.length) }, h("span", null, "Ange annat yrke \"", h("strong", null, inputValue.trim()), "\"")))));
31
+ };
@@ -0,0 +1,60 @@
1
+ import log from "loglevel";
2
+ let registeredTaxonomy = [];
3
+ let registeredVersion = null;
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function isNonEmptyString(value) {
8
+ return typeof value === 'string' && value.trim().length > 0;
9
+ }
10
+ function isOptionalString(value) {
11
+ return value === undefined || typeof value === 'string';
12
+ }
13
+ export function isTaxonomyItem(value) {
14
+ if (!isRecord(value))
15
+ return false;
16
+ return (isNonEmptyString(value.id)
17
+ && (value.type === 'job-title' || value.type === 'occupation-name')
18
+ && isNonEmptyString(value.preferred_label)
19
+ && isOptionalString(value.ssyk_level_4_id)
20
+ && isOptionalString(value.ssyk_code_2012)
21
+ && isOptionalString(value.occupation_name_id)
22
+ && isOptionalString(value.occupation_name_preferred_label)
23
+ && (value.weight === undefined || (typeof value.weight === 'number' && Number.isFinite(value.weight))));
24
+ }
25
+ export function isTaxonomyRegistration(value) {
26
+ if (!isRecord(value) || !Array.isArray(value.data) || value.data.length === 0) {
27
+ return false;
28
+ }
29
+ return value.data.every((item) => isTaxonomyItem(item));
30
+ }
31
+ /**
32
+ * Registrerar validerad taxonomidata för webbkomponenten.
33
+ * Ogiltig data ersätter aldrig en tidigare giltig registrering.
34
+ */
35
+ export function registerTaxonomy(data) {
36
+ if (!isTaxonomyRegistration(data)) {
37
+ log.error('[TaxonomyRegistry] Felaktigt format på taxonomidata vid registrering.');
38
+ return;
39
+ }
40
+ registeredTaxonomy = data.data.map((item) => ({ ...item }));
41
+ const metadataVersion = isRecord(data.metadata)
42
+ ? data.metadata.labour_market_taxonomy_version
43
+ : undefined;
44
+ registeredVersion = metadataVersion !== undefined
45
+ ? String(metadataVersion)
46
+ : (data.taxonomyVersion !== undefined ? String(data.taxonomyVersion) : 'unknown');
47
+ log.debug(`[TaxonomyRegistry] Taxonomi version ${registeredVersion} registrerades med ${registeredTaxonomy.length} yrken.`);
48
+ }
49
+ /** Hämtar den registrerade taxonomin. */
50
+ export function getRegisteredTaxonomy() {
51
+ if (registeredTaxonomy.length === 0) {
52
+ log.warn('[TaxonomyRegistry] Ingen taxonomi är registrerad ännu! Anropa registerTaxonomy(data) först.');
53
+ }
54
+ return registeredTaxonomy;
55
+ }
56
+ /** Endast för testning: återställer registret. */
57
+ export function __resetRegistry() {
58
+ registeredTaxonomy = [];
59
+ registeredVersion = null;
60
+ }