@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,835 @@
1
+ import { TaxonomyAdapter } from "./logic/adapter.utils";
2
+ import { HistoryService } from "./history-service";
3
+ import { getRegisteredTaxonomy } from "./taxonomy-registry";
4
+ import { segmentsToHtml } from "./search.utils";
5
+ import { DEBOUNCE_TIME, MAX_RESULTS } from "./constants";
6
+ import log from "loglevel";
7
+ // NEW IMPORTS
8
+ import { TaxonomySearchEngine } from "./logic/search.engine";
9
+ import { LabelFormatter } from "./logic/label-formatter";
10
+ export class JobSelectorController {
11
+ callbacks;
12
+ config;
13
+ state = { status: 'loading' };
14
+ taxonomyData = [];
15
+ popularItems = [];
16
+ // REPLACED: private fuseInstance: Fuse<TaxonomyItem> | null = null;
17
+ searchEngine = null;
18
+ debounceTimer;
19
+ validationTimer;
20
+ isTouched = false;
21
+ activeErrorType = null;
22
+ constructor(callbacks, config) {
23
+ this.callbacks = callbacks;
24
+ this.config = config;
25
+ }
26
+ // --- PUBLIC API ---
27
+ getState() {
28
+ return this.state;
29
+ }
30
+ getActiveErrorType() {
31
+ return this.activeErrorType;
32
+ }
33
+ // --- CONFIG UPDATE ---
34
+ updateConfig(newConfig) {
35
+ this.config = { ...this.config, ...newConfig };
36
+ // Kör validering om reglerna för obligatoriskt fält eller fritext ändras.
37
+ if (newConfig.required !== undefined || newConfig.requireSelection !== undefined) {
38
+ this.runValidation();
39
+ }
40
+ }
41
+ getTaxonomyData() {
42
+ // Return internal data if already loaded
43
+ if (this.taxonomyData.length > 0)
44
+ return this.taxonomyData;
45
+ // Try to fetch from registry (handles late registration)
46
+ const registeredData = getRegisteredTaxonomy();
47
+ if (registeredData && registeredData.length > 0) {
48
+ log.debug('[Controller] getTaxonomyData - Late initializing from registry');
49
+ this.taxonomyData = registeredData;
50
+ this.popularItems = this.computePopularItems(this.taxonomyData);
51
+ this.searchEngine = new TaxonomySearchEngine(this.taxonomyData);
52
+ }
53
+ return this.taxonomyData;
54
+ }
55
+ destroy() {
56
+ if (this.debounceTimer) {
57
+ clearTimeout(this.debounceTimer);
58
+ this.debounceTimer = undefined;
59
+ }
60
+ if (this.validationTimer) {
61
+ clearTimeout(this.validationTimer);
62
+ this.validationTimer = undefined;
63
+ }
64
+ }
65
+ // --- INITIALIZATION ---
66
+ async initialize(initialData) {
67
+ log.debug('[Controller] initialize - START', { hasInitialData: !!initialData });
68
+ this.updateState({ status: 'loading' });
69
+ const registeredData = (initialData && initialData.length > 0) ? initialData : getRegisteredTaxonomy();
70
+ if (registeredData && registeredData.length > 0) {
71
+ log.debug('[Controller] initialize - Loaded taxonomy from registry');
72
+ this.setStateToActive(registeredData, 'registry', new Date().toISOString());
73
+ }
74
+ else {
75
+ log.warn('[Controller] initialize - No taxonomy found in registry. Has registerTaxonomy() been called?');
76
+ this.updateState({ status: 'error', message: 'Kunde inte hitta yrkesdata. Kontrollera att komponenten är initierad korrekt.' });
77
+ }
78
+ }
79
+ handleFocus() {
80
+ this.isTouched = true;
81
+ }
82
+ setStateToActive(data, source, sourceDate) {
83
+ this.taxonomyData = data;
84
+ this.popularItems = this.computePopularItems(this.taxonomyData);
85
+ // NEW: Initialize the separated SearchEngine
86
+ this.searchEngine = new TaxonomySearchEngine(this.taxonomyData);
87
+ // OLD: this.fuseInstance = new Fuse(this.taxonomyData, FUSE_OPTIONS);
88
+ const commonState = { dataSource: source, dataSourceDate: sourceDate };
89
+ if (this.config.enableMultiple) {
90
+ this.updateState({
91
+ status: 'multiSelect',
92
+ inputValue: '',
93
+ selectedItems: [],
94
+ suggestions: [],
95
+ validation: 'idle',
96
+ ...commonState
97
+ });
98
+ return;
99
+ }
100
+ this.updateState({ status: 'searching', inputValue: '', suggestions: [], validation: 'idle', ...commonState });
101
+ }
102
+ // --- SEARCH ---
103
+ handleInput(value) {
104
+ this.isTouched = true;
105
+ log.debug('[Controller] handleInput - Value:', value);
106
+ // Reset validation on input
107
+ const wasInError = this.state.validation === 'error';
108
+ const validationContext = wasInError || this.state.validationContext === 'from-error' ? 'from-error' : undefined;
109
+ clearTimeout(this.debounceTimer);
110
+ if (value.trim() === '') {
111
+ if (this.config.enableMultiple) {
112
+ const currentSelected = 'selectedItems' in this.state ? this.state.selectedItems : [];
113
+ this.updateState({
114
+ status: 'multiSelect',
115
+ inputValue: '',
116
+ selectedItems: currentSelected,
117
+ suggestions: [],
118
+ validation: 'idle',
119
+ validationContext, // BEVARA CONTEXT
120
+ ...this.getCommonState()
121
+ });
122
+ }
123
+ else {
124
+ this.updateState({
125
+ status: 'searching',
126
+ inputValue: '',
127
+ suggestions: [],
128
+ validation: 'idle',
129
+ validationContext, // BEVARA CONTEXT
130
+ ...this.getCommonState()
131
+ });
132
+ }
133
+ this.showHistory();
134
+ return;
135
+ }
136
+ switch (this.state.status) {
137
+ case 'multiSelect':
138
+ case 'multiSelectShowingHistory': {
139
+ const selectedItems = 'selectedItems' in this.state ? this.state.selectedItems : [];
140
+ this.updateState({
141
+ status: 'multiSelect',
142
+ inputValue: value,
143
+ selectedItems,
144
+ suggestions: 'suggestions' in this.state ? this.state.suggestions : ('historyItems' in this.state ? this.state.historyItems : []),
145
+ validation: 'idle',
146
+ validationContext,
147
+ dataSource: 'dataSource' in this.state ? this.state.dataSource : undefined,
148
+ dataSourceDate: 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined
149
+ });
150
+ break;
151
+ }
152
+ case 'itemSelected':
153
+ case 'searching':
154
+ case 'showingHistory': {
155
+ this.updateState({
156
+ status: 'searching',
157
+ inputValue: value,
158
+ suggestions: 'suggestions' in this.state ? this.state.suggestions : ('historyItems' in this.state ? this.state.historyItems : []),
159
+ validation: 'idle',
160
+ validationContext,
161
+ dataSource: 'dataSource' in this.state ? this.state.dataSource : undefined,
162
+ dataSourceDate: 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined
163
+ });
164
+ break;
165
+ }
166
+ case 'loading':
167
+ case 'error':
168
+ return; // Interaction not allowed or ignored
169
+ default: {
170
+ const _exhaustive = this.state;
171
+ return _exhaustive;
172
+ }
173
+ }
174
+ if (value.length >= this.config.minLength) {
175
+ this.debounceTimer = setTimeout(() => this.performSearch(value), DEBOUNCE_TIME);
176
+ }
177
+ }
178
+ performSearch(value) {
179
+ if (!this.searchEngine)
180
+ return;
181
+ switch (this.state.status) {
182
+ case 'searching':
183
+ case 'multiSelect': {
184
+ const genericResults = this.searchEngine.searchAsGeneric(value, this.config.maxResults);
185
+ const results = genericResults.flatMap(g => g.metadata?.originalResult ? [g.metadata.originalResult] : []);
186
+ // Do not filter out selected items here - allow UI to handle grey-out
187
+ this.updateState({ ...this.state, suggestions: results });
188
+ break;
189
+ }
190
+ case 'loading':
191
+ case 'error':
192
+ case 'itemSelected':
193
+ case 'showingHistory':
194
+ case 'multiSelectShowingHistory':
195
+ return; // Nothing to search from here
196
+ default: {
197
+ const _exhaustive = this.state;
198
+ return _exhaustive;
199
+ }
200
+ }
201
+ }
202
+ // --- HISTORY HANDLING ---
203
+ addToHistory(item) {
204
+ if (!this.config.history)
205
+ return;
206
+ // Reload history from cache to ensure we have the latest state from other instances
207
+ const historyItems = HistoryService.loadHistory();
208
+ // Clone item and reset highlightedLabel to displayLabel (removes bolding)
209
+ // AND apply grey styling to parenthesis (via DisplaySegments)
210
+ const unhighlightedSegments = LabelFormatter.format(item.rawLabel, '', item.occupationLabel);
211
+ const historyItem = {
212
+ ...item,
213
+ displaySegments: unhighlightedSegments,
214
+ // Legacy support: construct HTML string for fallback
215
+ highlightedLabel: segmentsToHtml(unhighlightedSegments)
216
+ };
217
+ // Filter out existing item with same ID (to move it to top)
218
+ const filtered = historyItems.filter((h) => {
219
+ if (h.id && historyItem.id)
220
+ return h.id !== historyItem.id;
221
+ return h.rawLabel !== historyItem.rawLabel;
222
+ });
223
+ // Add to top and limit
224
+ const newHistory = [historyItem, ...filtered].slice(0, this.config.maxHistoryItems);
225
+ HistoryService.saveHistory(newHistory);
226
+ }
227
+ clearHistory() {
228
+ HistoryService.clearHistory();
229
+ this.showHistory(true);
230
+ }
231
+ computePopularItems(data, limit = this.config.maxResults || MAX_RESULTS) {
232
+ if (!data || data.length === 0)
233
+ return [];
234
+ const hasWeights = data.some(t => typeof t.weight === 'number' && t.weight > 0);
235
+ if (!hasWeights) {
236
+ return data.slice(0, limit);
237
+ }
238
+ const sorted = [...data].sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0));
239
+ const seen = new Set();
240
+ const popular = [];
241
+ for (const item of sorted) {
242
+ const key = item.occupation_name_preferred_label || item.preferred_label;
243
+ if (!seen.has(key)) {
244
+ seen.add(key);
245
+ popular.push(item);
246
+ }
247
+ if (popular.length === limit)
248
+ break;
249
+ }
250
+ return popular;
251
+ }
252
+ showHistory(force = false) {
253
+ if (this.state.status === 'loading' || this.state.status === 'error')
254
+ return;
255
+ if (!this.config.history)
256
+ return;
257
+ const historyItems = HistoryService.loadHistory();
258
+ let displayItems = historyItems;
259
+ let isPopular = false;
260
+ if (historyItems.length === 0) {
261
+ if (this.config.popular === false) {
262
+ displayItems = [];
263
+ }
264
+ else {
265
+ if (this.popularItems.length === 0 && this.taxonomyData.length > 0) {
266
+ this.popularItems = this.computePopularItems(this.taxonomyData);
267
+ }
268
+ // Provide some popular items if history is empty (and we have taxonomy data)
269
+ displayItems = this.popularItems.map(t => this.toSearchResult(t));
270
+ isPopular = true;
271
+ }
272
+ }
273
+ // Lazy Validation: Filter items against current taxonomy and config
274
+ const validatedHistory = displayItems.filter((item) => {
275
+ // 1. Check if it's a ManualInput item
276
+ if (item.type === 'ManualInput') {
277
+ return !this.config.requireSelection;
278
+ }
279
+ // 2. Check if it's a valid Taxonomy item (must exist in current data)
280
+ // Note: We use this.taxonomyData which is already loaded
281
+ const exists = this.taxonomyData.some(t => t.id === item.id);
282
+ return exists;
283
+ });
284
+ const filteredHistory = validatedHistory.map((item) => {
285
+ const unhighlightedSegments = LabelFormatter.format(item.rawLabel, '', item.occupationLabel);
286
+ return {
287
+ ...item,
288
+ displaySegments: unhighlightedSegments,
289
+ highlightedLabel: segmentsToHtml(unhighlightedSegments)
290
+ };
291
+ });
292
+ const { dataSource, dataSourceDate } = this.getCommonState();
293
+ const currentInputValue = 'inputValue' in this.state ? this.state.inputValue : '';
294
+ // If we have input text, we don't want to show history unless explicitly in a history state
295
+ // OR if we are forcing it (e.g. from editSelection)
296
+ if (!force && currentInputValue.trim() !== '' && this.state.status !== 'showingHistory' && this.state.status !== 'multiSelectShowingHistory') {
297
+ return;
298
+ }
299
+ if (this.config.enableMultiple) {
300
+ const selectedItems = 'selectedItems' in this.state ? this.state.selectedItems : [];
301
+ const wasInError = this.state.validation === 'error' || this.state.validationContext === 'from-error';
302
+ const validationContext = wasInError ? 'from-error' : undefined;
303
+ const validation = 'validation' in this.state ? this.state.validation : 'idle';
304
+ this.updateState({
305
+ status: 'multiSelectShowingHistory',
306
+ selectedItems,
307
+ historyItems: filteredHistory,
308
+ inputValue: currentInputValue,
309
+ validation,
310
+ validationContext,
311
+ dataSource,
312
+ dataSourceDate,
313
+ isPopularView: isPopular // Flag for view
314
+ });
315
+ }
316
+ else {
317
+ const wasInError = this.state.validation === 'error' || this.state.validationContext === 'from-error';
318
+ const validationContext = wasInError ? 'from-error' : undefined;
319
+ const validation = 'validation' in this.state ? this.state.validation : 'idle';
320
+ this.updateState({
321
+ status: 'showingHistory',
322
+ historyItems: filteredHistory,
323
+ inputValue: currentInputValue,
324
+ validation,
325
+ validationContext,
326
+ dataSource,
327
+ dataSourceDate,
328
+ isPopularView: isPopular // Flag for view
329
+ });
330
+ }
331
+ }
332
+ // --- SELECTION ---
333
+ selectItem(item) {
334
+ log.debug('[Controller] selectItem', item);
335
+ // Add to history
336
+ this.isTouched = true;
337
+ this.addToHistory(item);
338
+ // Standardized Success Message Logic:
339
+ // Check for error state before update to allow transition to success.
340
+ // This applies to ALL selection events (Single, Multi, History, Search).
341
+ const wasInError = this.state.validation === 'error' || this.state.validationContext === 'from-error';
342
+ const nextValidationContext = wasInError ? 'from-error' : undefined;
343
+ switch (this.state.status) {
344
+ case 'multiSelect':
345
+ case 'multiSelectShowingHistory': {
346
+ const dataSource = 'dataSource' in this.state ? this.state.dataSource : undefined;
347
+ const dataSourceDate = 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined;
348
+ const currentSelected = 'selectedItems' in this.state ? this.state.selectedItems : [];
349
+ // Prevent duplicates
350
+ const existingIndex = currentSelected.findIndex(existing => {
351
+ if (existing.id && item.id)
352
+ return existing.id === item.id;
353
+ return existing.rawLabel === item.rawLabel;
354
+ });
355
+ if (existingIndex > -1) {
356
+ // Already selected - skip to avoid double addition or accidental removal in this context
357
+ return;
358
+ }
359
+ const newItems = [...currentSelected, item];
360
+ // Uppdatera state: rensa input och förslag
361
+ this.updateState({
362
+ ...this.state,
363
+ status: 'multiSelect',
364
+ selectedItems: newItems,
365
+ inputValue: '',
366
+ suggestions: [],
367
+ validation: 'idle',
368
+ validationContext: nextValidationContext, // Use centralized context
369
+ dataSource,
370
+ dataSourceDate
371
+ });
372
+ this.callbacks.onEmitSelection(newItems.map(i => this.toPublicSelection(i)));
373
+ this.showHistory(true);
374
+ break;
375
+ }
376
+ case 'searching':
377
+ case 'showingHistory': {
378
+ const dataSource = 'dataSource' in this.state ? this.state.dataSource : undefined;
379
+ const dataSourceDate = 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined;
380
+ this.updateState({
381
+ status: 'itemSelected',
382
+ selectedItem: item,
383
+ inputValue: item.preferred_label, // Keep label as text
384
+ validation: 'idle',
385
+ validationContext: nextValidationContext, // Use centralized context
386
+ dataSource,
387
+ dataSourceDate,
388
+ });
389
+ this.callbacks.onEmitSelection(this.toPublicSelection(item));
390
+ if (this.config.enableMultiple) {
391
+ this.showHistory(true);
392
+ }
393
+ break;
394
+ }
395
+ case 'loading':
396
+ case 'error':
397
+ // Do nothing in these states
398
+ break;
399
+ case 'itemSelected': {
400
+ const dataSource = 'dataSource' in this.state ? this.state.dataSource : undefined;
401
+ const dataSourceDate = 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined;
402
+ this.updateState({
403
+ ...this.state,
404
+ status: 'itemSelected',
405
+ selectedItem: item,
406
+ inputValue: item.preferred_label,
407
+ validation: 'idle',
408
+ validationContext: nextValidationContext,
409
+ dataSource,
410
+ dataSourceDate,
411
+ });
412
+ this.callbacks.onEmitSelection(this.toPublicSelection(item));
413
+ break;
414
+ }
415
+ default: {
416
+ const _exhaustive = this.state;
417
+ log.warn('[Controller] selectItem - Unexpected state:', _exhaustive);
418
+ }
419
+ }
420
+ this.runValidation();
421
+ }
422
+ clearSelection() {
423
+ if (this.state.status !== 'itemSelected')
424
+ return;
425
+ this.isTouched = true;
426
+ const currentInputValue = this.state.inputValue || '';
427
+ this.updateState({
428
+ status: 'searching',
429
+ inputValue: currentInputValue,
430
+ suggestions: [],
431
+ validation: 'idle',
432
+ ...this.getCommonState()
433
+ });
434
+ this.callbacks.onEmitSelection([]); // Empty array instead of null
435
+ // Try to show history if enabled
436
+ this.showHistory();
437
+ this.runValidation();
438
+ }
439
+ removeItem(item) {
440
+ switch (this.state.status) {
441
+ case 'multiSelect':
442
+ case 'multiSelectShowingHistory': {
443
+ const newItems = this.state.selectedItems.filter(i => {
444
+ if (i.id && item.id)
445
+ return i.id !== item.id;
446
+ return i.rawLabel !== item.rawLabel;
447
+ });
448
+ this.isTouched = true;
449
+ this.updateState({ ...this.state, selectedItems: newItems });
450
+ this.callbacks.onEmitSelection(newItems.map(i => this.toPublicSelection(i)));
451
+ // Try to show history if enabled (since we are likely still focused)
452
+ const inputValue = 'inputValue' in this.state ? this.state.inputValue : '';
453
+ if (inputValue === '') {
454
+ this.showHistory();
455
+ }
456
+ this.runValidation();
457
+ break;
458
+ }
459
+ default:
460
+ break;
461
+ }
462
+ }
463
+ // --- BLUR & VALIDATION ---
464
+ handleBlur() {
465
+ this.isTouched = true;
466
+ if (this.state.status === 'searching') {
467
+ const trimmedValue = (this.state.inputValue || '').trim();
468
+ if (trimmedValue !== '') {
469
+ const exactMatch = this.findExactMatch(trimmedValue);
470
+ if (exactMatch) {
471
+ this.selectItem(this.toSearchResult(exactMatch));
472
+ return;
473
+ }
474
+ }
475
+ }
476
+ // In TEA/FSM, handleBlur should focus on state transitions and validation,
477
+ // rather than side-effects like clearing UI data.
478
+ // We transition back to primary states (searching/multiSelect) but KEEP suggestions.
479
+ switch (this.state.status) {
480
+ case 'searching':
481
+ case 'showingHistory':
482
+ this.updateState({
483
+ ...this.state,
484
+ status: 'searching',
485
+ inputValue: 'inputValue' in this.state ? this.state.inputValue : '',
486
+ suggestions: []
487
+ });
488
+ break;
489
+ case 'multiSelect':
490
+ case 'multiSelectShowingHistory': {
491
+ this.updateState({
492
+ ...this.state,
493
+ status: 'multiSelect',
494
+ inputValue: 'inputValue' in this.state ? this.state.inputValue : '',
495
+ suggestions: []
496
+ });
497
+ break;
498
+ }
499
+ case 'loading':
500
+ case 'error':
501
+ case 'itemSelected':
502
+ break;
503
+ default: {
504
+ const _exhaustive = this.state;
505
+ return _exhaustive;
506
+ }
507
+ }
508
+ this.runValidation();
509
+ }
510
+ /**
511
+ * Validerar nuvarande värde och sätter eventuella valideringsfel.
512
+ * @param forceTouch - Tvingar valideringen att köras (och därmed visa fel) även om fältet inte tidigare blivit rört (touched). Default är false.
513
+ */
514
+ runValidation(forceTouch = false) {
515
+ if (forceTouch) {
516
+ this.isTouched = true;
517
+ }
518
+ if (!this.isTouched)
519
+ return true;
520
+ if (this.state.status === 'loading' || this.state.status === 'error')
521
+ return true;
522
+ const hasValue = this.checkHasValue();
523
+ let isValid = true;
524
+ let newErrorType = null;
525
+ // Check for invalid input (text that doesn't match a selection)
526
+ // This applies in both single 'searching' mode and 'multiSelect' mode
527
+ const isSearchingOrMulti = this.state.status === 'searching' || this.state.status === 'multiSelect' || this.state.status === 'multiSelectShowingHistory';
528
+ const inputValue = 'inputValue' in this.state ? this.state.inputValue : '';
529
+ const hasInputText = inputValue.trim() !== '';
530
+ if (isSearchingOrMulti && hasInputText) {
531
+ // New rule: If there is text in the box that isn't confirmed as a selection, it's ALWAYS an error
532
+ // regardless of whether manual input is enabled or not.
533
+ // The user MUST confirm by selecting the manual input option.
534
+ isValid = false;
535
+ newErrorType = 'invalidInput';
536
+ }
537
+ else if (this.config.required && !hasValue) {
538
+ isValid = false;
539
+ newErrorType = 'required';
540
+ }
541
+ this.activeErrorType = newErrorType;
542
+ // Strict FSM for validation state
543
+ // Rules:
544
+ // 1. Error only if invalid.
545
+ // 2. Idle if valid (never use success to avoid aggressive green checks).
546
+ // Valid state: only show success if recovering from an error (Red -> Green pattern) AND we actually have a value.
547
+ const wasInError = this.state.validation === 'error' || this.state.validationContext === 'from-error';
548
+ let validationState;
549
+ if (!isValid) {
550
+ validationState = newErrorType === 'invalidInput' || forceTouch || wasInError ? 'error' : 'idle';
551
+ }
552
+ else if (wasInError && hasValue) {
553
+ validationState = 'success';
554
+ }
555
+ else {
556
+ validationState = 'idle';
557
+ }
558
+ if (validationState === 'success') {
559
+ clearTimeout(this.validationTimer);
560
+ this.validationTimer = setTimeout(() => {
561
+ // Only clear if still success (user hasn't messed up again)
562
+ const isSuccess = 'validation' in this.state && this.state.validation === 'success';
563
+ const allowedStatus = ['itemSelected', 'multiSelect', 'multiSelectShowingHistory'].includes(this.state.status);
564
+ if (allowedStatus && isSuccess) {
565
+ this.updateState({ ...this.state, validation: 'idle', validationContext: undefined });
566
+ }
567
+ this.validationTimer = undefined;
568
+ }, 3000);
569
+ }
570
+ // Update state based on current status (respecting type definitions)
571
+ switch (this.state.status) {
572
+ case 'searching':
573
+ this.updateState({ ...this.state, validation: validationState });
574
+ break;
575
+ case 'showingHistory':
576
+ this.updateState({ ...this.state, validation: validationState });
577
+ break;
578
+ case 'multiSelect':
579
+ // Allow error AND success states for multiSelect
580
+ this.updateState({ ...this.state, validation: validationState });
581
+ break;
582
+ case 'multiSelectShowingHistory':
583
+ this.updateState({ ...this.state, validation: validationState });
584
+ break;
585
+ case 'itemSelected':
586
+ // ItemSelected does not support 'error'. But checkHasValue() returns true, so isValid is true.
587
+ if (validationState !== 'error') {
588
+ this.updateState({ ...this.state, validation: validationState });
589
+ }
590
+ break;
591
+ default: {
592
+ // If we reach here, we must have handled all states that aren't 'loading' or 'error'
593
+ // But TypeScript needs to know that.
594
+ const _exhaustive = this.state;
595
+ return _exhaustive;
596
+ }
597
+ }
598
+ this.callbacks.onEmitValidation({ isValid, errorType: newErrorType });
599
+ return isValid;
600
+ }
601
+ selectItemById(id) {
602
+ if (!id || this.config.enableMultiple || !this.taxonomyData)
603
+ return;
604
+ const itemToSelect = this.taxonomyData.find(t => t.id === id);
605
+ if (itemToSelect) {
606
+ const searchResult = this.toSearchResult(itemToSelect);
607
+ const dataSource = 'dataSource' in this.state ? this.state.dataSource : undefined;
608
+ const dataSourceDate = 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined;
609
+ this.updateState({
610
+ status: 'itemSelected',
611
+ selectedItem: searchResult,
612
+ inputValue: searchResult.preferred_label,
613
+ validation: 'idle',
614
+ dataSource,
615
+ dataSourceDate,
616
+ });
617
+ }
618
+ this.runValidation();
619
+ }
620
+ selectItemsByIds(ids) {
621
+ if (!ids || !this.config.enableMultiple || !this.taxonomyData)
622
+ return;
623
+ const taxonomyItems = ids.map(id => this.taxonomyData.find(item => item.id === id));
624
+ if (taxonomyItems.some((item) => !item))
625
+ return;
626
+ const newSelectedItems = taxonomyItems
627
+ .filter((item) => !!item)
628
+ .map(item => this.toSearchResult(item));
629
+ switch (this.state.status) {
630
+ case 'multiSelect':
631
+ case 'multiSelectShowingHistory':
632
+ this.updateState({
633
+ ...this.state,
634
+ selectedItems: newSelectedItems
635
+ });
636
+ break;
637
+ default:
638
+ break;
639
+ }
640
+ this.runValidation();
641
+ }
642
+ setSelection(selection) {
643
+ const invalidIds = [];
644
+ const selectedItems = selection.map((item) => {
645
+ if (item.id !== null) {
646
+ const taxonomyItem = this.taxonomyData.find((taxonomy) => taxonomy.id === item.id);
647
+ if (!taxonomyItem) {
648
+ invalidIds.push(item.id);
649
+ return undefined;
650
+ }
651
+ return this.toSearchResult(taxonomyItem);
652
+ }
653
+ return this.createManualInputItem(item.preferred_label);
654
+ });
655
+ if (invalidIds.length > 0) {
656
+ return { ok: false, invalidIds };
657
+ }
658
+ const resolvedItems = selectedItems;
659
+ const commonState = this.getCommonState();
660
+ if (this.config.enableMultiple) {
661
+ this.updateState({
662
+ ...this.state,
663
+ status: 'multiSelect',
664
+ selectedItems: resolvedItems,
665
+ inputValue: '',
666
+ suggestions: [],
667
+ validation: 'idle',
668
+ ...commonState
669
+ });
670
+ }
671
+ else if (resolvedItems.length === 1) {
672
+ const selectedItem = resolvedItems[0];
673
+ this.updateState({
674
+ status: 'itemSelected',
675
+ selectedItem,
676
+ inputValue: selectedItem.preferred_label,
677
+ validation: 'idle',
678
+ ...commonState
679
+ });
680
+ }
681
+ else {
682
+ this.updateState({
683
+ status: 'searching',
684
+ inputValue: '',
685
+ suggestions: [],
686
+ validation: 'idle',
687
+ ...commonState
688
+ });
689
+ }
690
+ this.runValidation();
691
+ return { ok: true, selection: resolvedItems.map((item) => this.toPublicSelection(item)) };
692
+ }
693
+ editSelection() {
694
+ const selectedItem = this.state.selectedItem;
695
+ if (this.state.status !== 'itemSelected' || !selectedItem || !this.searchEngine)
696
+ return;
697
+ // Transition to searching first to ensure input shows up
698
+ const professionLabel = selectedItem.displayLabel;
699
+ const { dataSource, dataSourceDate } = this.state;
700
+ // Rule: On activation, show search results for the current string instead of history
701
+ const genericResults = this.searchEngine.searchAsGeneric(professionLabel, this.config.maxResults);
702
+ const searchResults = genericResults.flatMap(g => g.metadata?.originalResult ? [g.metadata.originalResult] : []);
703
+ this.updateState({
704
+ status: 'searching',
705
+ inputValue: professionLabel,
706
+ suggestions: searchResults,
707
+ validation: 'idle',
708
+ dataSource,
709
+ dataSourceDate
710
+ });
711
+ }
712
+ checkHasValue() {
713
+ switch (this.state.status) {
714
+ case 'itemSelected':
715
+ return true;
716
+ case 'multiSelect':
717
+ case 'multiSelectShowingHistory':
718
+ return this.state.selectedItems.length > 0;
719
+ case 'searching':
720
+ case 'showingHistory': {
721
+ const inputValue = 'inputValue' in this.state ? this.state.inputValue : '';
722
+ return !this.config.requireSelection && inputValue.trim() !== '';
723
+ }
724
+ case 'loading':
725
+ case 'error':
726
+ return false;
727
+ default: {
728
+ const _exhaustive = this.state;
729
+ return _exhaustive;
730
+ }
731
+ }
732
+ }
733
+ getCommonState() {
734
+ return {
735
+ dataSource: 'dataSource' in this.state ? this.state.dataSource : undefined,
736
+ dataSourceDate: 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined
737
+ };
738
+ }
739
+ resetMultiSelection() {
740
+ const commonState = this.getCommonState();
741
+ this.updateState({
742
+ ...this.state,
743
+ status: 'multiSelect',
744
+ selectedItems: [],
745
+ inputValue: '',
746
+ suggestions: [],
747
+ validation: 'idle',
748
+ ...commonState
749
+ });
750
+ this.callbacks.onEmitSelection([]);
751
+ }
752
+ // --- HELPERS ---
753
+ updateState(newState) {
754
+ this.state = newState;
755
+ this.callbacks.onStateChange(newState);
756
+ }
757
+ toSearchResult(item) {
758
+ return {
759
+ ...item,
760
+ rawLabel: item.preferred_label,
761
+ displayLabel: item.occupation_name_preferred_label
762
+ ? `${item.preferred_label} (${item.occupation_name_preferred_label})`
763
+ : item.preferred_label,
764
+ highlightedLabel: item.preferred_label,
765
+ score: 3.0,
766
+ // Map v2 fields to match SearchResult interface expectations if needed
767
+ occupationLabel: item.occupation_name_preferred_label,
768
+ occupationId: item.occupation_name_id,
769
+ ssyk_level_4_id: item.ssyk_level_4_id,
770
+ ssyk_code_2012: item.ssyk_code_2012
771
+ };
772
+ }
773
+ toPublicSelection(item) {
774
+ return TaxonomyAdapter.toPublicSelection(item);
775
+ }
776
+ createManualInputItem(value) {
777
+ return {
778
+ id: '',
779
+ type: 'ManualInput',
780
+ preferred_label: value,
781
+ rawLabel: value,
782
+ displayLabel: value,
783
+ highlightedLabel: value,
784
+ score: 1.0
785
+ };
786
+ }
787
+ findExactMatch(value) {
788
+ return this.taxonomyData.find(item => {
789
+ const displayLabel = item.occupation_name_preferred_label
790
+ ? `${item.preferred_label} (${item.occupation_name_preferred_label})`
791
+ : item.preferred_label;
792
+ return displayLabel.toLowerCase() === value.toLowerCase() || item.preferred_label.toLowerCase() === value.toLowerCase();
793
+ });
794
+ }
795
+ // --- FREETEXT HANDLING (used by keyboard handler) ---
796
+ handleFreeTextSelection(value) {
797
+ if (!value)
798
+ return;
799
+ this.isTouched = true;
800
+ const exactMatch = this.findExactMatch(value);
801
+ if (exactMatch) {
802
+ this.selectItem(this.toSearchResult(exactMatch));
803
+ }
804
+ else {
805
+ this.selectItem(this.createManualInputItem(value));
806
+ }
807
+ }
808
+ loadMore() {
809
+ // Increment maxResults or handle paging if supported by searchEngine
810
+ this.config.maxResults += 10;
811
+ const inputValue = 'inputValue' in this.state ? this.state.inputValue : '';
812
+ if (inputValue) {
813
+ this.performSearch(inputValue);
814
+ }
815
+ }
816
+ resetSearch() {
817
+ switch (this.state.status) {
818
+ case 'searching':
819
+ case 'multiSelect':
820
+ this.updateState({ ...this.state, inputValue: '', suggestions: [] });
821
+ this.showHistory(true);
822
+ break;
823
+ case 'showingHistory':
824
+ case 'multiSelectShowingHistory':
825
+ // Already showing history, just ensure input is empty
826
+ this.updateState({ ...this.state, inputValue: '' });
827
+ break;
828
+ case 'itemSelected':
829
+ this.clearSelection();
830
+ break;
831
+ default:
832
+ break;
833
+ }
834
+ }
835
+ }