@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.
- package/LICENSE +190 -0
- package/README.md +369 -0
- package/dist/cjs/af-validation-message-BzY5H0xU.js +16 -0
- package/dist/cjs/core-selector.cjs.entry.js +476 -0
- package/dist/cjs/digi-service-job-selector.cjs.entry.js +1619 -0
- package/dist/cjs/fuse-BTLNKxuL.js +1631 -0
- package/dist/cjs/geo-valjaren.cjs.entry.js +14910 -0
- package/dist/cjs/index-BOuTGlEp.js +1817 -0
- package/dist/cjs/index.cjs.js +14 -0
- package/dist/cjs/job-selector-demo.cjs.entry.js +692 -0
- package/dist/cjs/loader.cjs.js +12 -0
- package/dist/cjs/neobrutal-search.cjs.entry.js +171 -0
- package/dist/cjs/search.engine-D0cMelRi.js +489 -0
- package/dist/cjs/taxonomy-registry-DVmuDUpM.js +435 -0
- package/dist/cjs/types-CtHBl6Ra.js +14 -0
- package/dist/cjs/yrkesvaljaren.cjs.js +24 -0
- package/dist/collection/collection-manifest.json +15 -0
- package/dist/collection/components/core-selector/core-selector.css +803 -0
- package/dist/collection/components/core-selector/core-selector.js +761 -0
- package/dist/collection/components/core-selector/types.js +1 -0
- package/dist/collection/components/design-compat/af-icon.js +13 -0
- package/dist/collection/components/design-compat/af-label.js +2 -0
- package/dist/collection/components/design-compat/af-tag.js +6 -0
- package/dist/collection/components/design-compat/af-validation-message.js +11 -0
- package/dist/collection/components/design-compat/index.js +4 -0
- package/dist/collection/components/geovaljaren/geovaljaren.css +21 -0
- package/dist/collection/components/geovaljaren/geovaljaren.js +304 -0
- package/dist/collection/components/geovaljaren/logic/adapter.utils.js +20 -0
- package/dist/collection/components/geovaljaren/logic/data.utils.js +12 -0
- package/dist/collection/components/geovaljaren/logic/history-service.js +25 -0
- package/dist/collection/components/geovaljaren/logic/search.engine.js +28 -0
- package/dist/collection/components/job-selector/constants.js +26 -0
- package/dist/collection/components/job-selector/history-service.js +41 -0
- package/dist/collection/components/job-selector/job-selector-controller.js +835 -0
- package/dist/collection/components/job-selector/job-selector.css +318 -0
- package/dist/collection/components/job-selector/job-selector.js +1378 -0
- package/dist/collection/components/job-selector/keyboard-handlers.js +84 -0
- package/dist/collection/components/job-selector/logic/adapter.utils.js +75 -0
- package/dist/collection/components/job-selector/logic/label-formatter.js +118 -0
- package/dist/collection/components/job-selector/logic/search.engine.js +50 -0
- package/dist/collection/components/job-selector/search-logic.js +35 -0
- package/dist/collection/components/job-selector/search.utils.js +224 -0
- package/dist/collection/components/job-selector/suggestions-list.js +31 -0
- package/dist/collection/components/job-selector/taxonomy-registry.js +60 -0
- package/dist/collection/components/job-selector/test-data.mock.js +33 -0
- package/dist/collection/components/job-selector/types.js +10 -0
- package/dist/collection/components/job-selector/validation-message.js +24 -0
- package/dist/collection/demo-data.js +20 -0
- package/dist/collection/index.js +7 -0
- package/dist/collection/verkstad/verkstad.js +407 -0
- package/dist/components/core-selector.d.ts +11 -0
- package/dist/components/core-selector.js +1 -0
- package/dist/components/core-selector2.js +1 -0
- package/dist/components/digi-service-job-selector.d.ts +11 -0
- package/dist/components/digi-service-job-selector.js +1 -0
- package/dist/components/fuse.js +1 -0
- package/dist/components/geo-valjaren.d.ts +11 -0
- package/dist/components/geo-valjaren.js +1 -0
- package/dist/components/index.d.ts +35 -0
- package/dist/components/index.js +1 -0
- package/dist/components/job-selector-demo.d.ts +11 -0
- package/dist/components/job-selector-demo.js +1 -0
- package/dist/components/job-selector.js +1 -0
- package/dist/components/neobrutal-search.d.ts +11 -0
- package/dist/components/neobrutal-search.js +1 -0
- package/dist/components/search.engine.js +1 -0
- package/dist/components/types.js +1 -0
- package/dist/esm/af-validation-message-F8NIH2rC.js +14 -0
- package/dist/esm/core-selector.entry.js +474 -0
- package/dist/esm/digi-service-job-selector.entry.js +1617 -0
- package/dist/esm/fuse-BPnhMTul.js +1629 -0
- package/dist/esm/geo-valjaren.entry.js +14908 -0
- package/dist/esm/index-CGXZllj2.js +1806 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/job-selector-demo.entry.js +690 -0
- package/dist/esm/loader.js +10 -0
- package/dist/esm/neobrutal-search.entry.js +169 -0
- package/dist/esm/search.engine-nZ7AcXba.js +479 -0
- package/dist/esm/taxonomy-registry-Dw0VR9Aw.js +430 -0
- package/dist/esm/types-DHUftoZJ.js +12 -0
- package/dist/esm/yrkesvaljaren.js +20 -0
- package/dist/index.cjs.js +1 -0
- package/dist/index.js +1 -0
- package/dist/types/components/core-selector/core-selector.d.ts +46 -0
- package/dist/types/components/core-selector/types.d.ts +1 -0
- package/dist/types/components/design-compat/af-icon.d.ts +7 -0
- package/dist/types/components/design-compat/af-label.d.ts +8 -0
- package/dist/types/components/design-compat/af-tag.d.ts +8 -0
- package/dist/types/components/design-compat/af-validation-message.d.ts +9 -0
- package/dist/types/components/design-compat/index.d.ts +5 -0
- package/dist/types/components/geovaljaren/geovaljaren.d.ts +24 -0
- package/dist/types/components/geovaljaren/logic/adapter.utils.d.ts +7 -0
- package/dist/types/components/geovaljaren/logic/data.utils.d.ts +7 -0
- package/dist/types/components/geovaljaren/logic/history-service.d.ts +6 -0
- package/dist/types/components/geovaljaren/logic/search.engine.d.ts +7 -0
- package/dist/types/components/job-selector/ambient.d.ts +4 -0
- package/dist/types/components/job-selector/constants.d.ts +13 -0
- package/dist/types/components/job-selector/history-service.d.ts +6 -0
- package/dist/types/components/job-selector/job-selector-controller.d.ts +82 -0
- package/dist/types/components/job-selector/job-selector.d.ts +140 -0
- package/dist/types/components/job-selector/keyboard-handlers.d.ts +13 -0
- package/dist/types/components/job-selector/logic/adapter.utils.d.ts +12 -0
- package/dist/types/components/job-selector/logic/label-formatter.d.ts +4 -0
- package/dist/types/components/job-selector/logic/search.engine.d.ts +21 -0
- package/dist/types/components/job-selector/search-logic.d.ts +17 -0
- package/dist/types/components/job-selector/search.utils.d.ts +10 -0
- package/dist/types/components/job-selector/suggestions-list.d.ts +13 -0
- package/dist/types/components/job-selector/taxonomy-registry.d.ts +20 -0
- package/dist/types/components/job-selector/test-data.mock.d.ts +7 -0
- package/dist/types/components/job-selector/types.d.ts +107 -0
- package/dist/types/components/job-selector/validation-message.d.ts +8 -0
- package/dist/types/components/job-selector-demo/job-selector-demo.d.ts +55 -0
- package/dist/types/components/neobrutal-search/neobrutal-search.d.ts +18 -0
- package/dist/types/components.d.ts +425 -0
- package/dist/types/demo-data.d.ts +16 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/stencil-public-runtime.d.ts +1873 -0
- package/dist/types/verkstad/verkstad.d.ts +1 -0
- package/dist/yrkesvaljaren/fuse-B0eHOxow.js.map +1 -0
- package/dist/yrkesvaljaren/index-Cy4BCo5s.js.map +1 -0
- package/dist/yrkesvaljaren/index-DfJXTQqL.js.map +1 -0
- package/dist/yrkesvaljaren/index.esm.js +1 -0
- package/dist/yrkesvaljaren/index.esm.js.map +1 -0
- package/dist/yrkesvaljaren/p-16636162.entry.js +1 -0
- package/dist/yrkesvaljaren/p-273cf6df.entry.js +1 -0
- package/dist/yrkesvaljaren/p-9e48afa4.entry.js +1 -0
- package/dist/yrkesvaljaren/p-BPnhMTul.js +1 -0
- package/dist/yrkesvaljaren/p-CGXZllj2.js +3 -0
- package/dist/yrkesvaljaren/p-DHUftoZJ.js +1 -0
- package/dist/yrkesvaljaren/p-DYFAzvqX.js +1 -0
- package/dist/yrkesvaljaren/p-Dw0VR9Aw.js +1 -0
- package/dist/yrkesvaljaren/p-aef7b5ae.entry.js +1 -0
- package/dist/yrkesvaljaren/p-b0f16abd.entry.js +1 -0
- package/dist/yrkesvaljaren/p-uBrs3sSs.js +1 -0
- package/dist/yrkesvaljaren/search.engine-DPT6roRm.js.map +1 -0
- package/dist/yrkesvaljaren/taxonomy-registry-s5khf6s4.js.map +1 -0
- package/dist/yrkesvaljaren/types-DHUftoZJ.js.map +1 -0
- package/dist/yrkesvaljaren/yrkesvaljaren.css +1 -0
- package/dist/yrkesvaljaren/yrkesvaljaren.esm.js +1 -0
- package/dist/yrkesvaljaren/yrkesvaljaren.esm.js.map +1 -0
- package/loader/cdn.js +1 -0
- package/loader/index.cjs.js +1 -0
- package/loader/index.d.ts +24 -0
- package/loader/index.es2017.js +1 -0
- package/loader/index.js +2 -0
- package/package.json +126 -0
|
@@ -0,0 +1,1619 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var index = require('./index-BOuTGlEp.js');
|
|
4
|
+
var search_engine = require('./search.engine-D0cMelRi.js');
|
|
5
|
+
var taxonomyRegistry = require('./taxonomy-registry-DVmuDUpM.js');
|
|
6
|
+
var types = require('./types-CtHBl6Ra.js');
|
|
7
|
+
var afValidationMessage = require('./af-validation-message-BzY5H0xU.js');
|
|
8
|
+
require('./fuse-BTLNKxuL.js');
|
|
9
|
+
|
|
10
|
+
let memoryCache = null;
|
|
11
|
+
const HistoryService = {
|
|
12
|
+
saveHistory(history) {
|
|
13
|
+
memoryCache = history; // Omedelbar uppdatering av UI-state
|
|
14
|
+
try {
|
|
15
|
+
localStorage.setItem('job-selector_history', JSON.stringify(history));
|
|
16
|
+
taxonomyRegistry.log.debug('[HistoryService] saveHistory - History saved.');
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
taxonomyRegistry.log.error('Kunde inte spara historik.', e);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
loadHistory() {
|
|
23
|
+
if (memoryCache) {
|
|
24
|
+
return memoryCache;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const historyString = localStorage.getItem('job-selector_history');
|
|
28
|
+
if (historyString) {
|
|
29
|
+
memoryCache = JSON.parse(historyString);
|
|
30
|
+
return memoryCache;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
taxonomyRegistry.log.error('Kunde inte läsa historik.', e);
|
|
35
|
+
}
|
|
36
|
+
memoryCache = [];
|
|
37
|
+
return memoryCache;
|
|
38
|
+
},
|
|
39
|
+
clearHistory() {
|
|
40
|
+
memoryCache = [];
|
|
41
|
+
try {
|
|
42
|
+
localStorage.removeItem('job-selector_history');
|
|
43
|
+
taxonomyRegistry.log.debug('[HistoryService] clearHistory - History cleared.');
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
taxonomyRegistry.log.error('Kunde inte rensa historik.', e);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
class JobSelectorController {
|
|
52
|
+
callbacks;
|
|
53
|
+
config;
|
|
54
|
+
state = { status: 'loading' };
|
|
55
|
+
taxonomyData = [];
|
|
56
|
+
popularItems = [];
|
|
57
|
+
// REPLACED: private fuseInstance: Fuse<TaxonomyItem> | null = null;
|
|
58
|
+
searchEngine = null;
|
|
59
|
+
debounceTimer;
|
|
60
|
+
validationTimer;
|
|
61
|
+
isTouched = false;
|
|
62
|
+
activeErrorType = null;
|
|
63
|
+
constructor(callbacks, config) {
|
|
64
|
+
this.callbacks = callbacks;
|
|
65
|
+
this.config = config;
|
|
66
|
+
}
|
|
67
|
+
// --- PUBLIC API ---
|
|
68
|
+
getState() {
|
|
69
|
+
return this.state;
|
|
70
|
+
}
|
|
71
|
+
getActiveErrorType() {
|
|
72
|
+
return this.activeErrorType;
|
|
73
|
+
}
|
|
74
|
+
// --- CONFIG UPDATE ---
|
|
75
|
+
updateConfig(newConfig) {
|
|
76
|
+
this.config = { ...this.config, ...newConfig };
|
|
77
|
+
// Kör validering om reglerna för obligatoriskt fält eller fritext ändras.
|
|
78
|
+
if (newConfig.required !== undefined || newConfig.requireSelection !== undefined) {
|
|
79
|
+
this.runValidation();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
getTaxonomyData() {
|
|
83
|
+
// Return internal data if already loaded
|
|
84
|
+
if (this.taxonomyData.length > 0)
|
|
85
|
+
return this.taxonomyData;
|
|
86
|
+
// Try to fetch from registry (handles late registration)
|
|
87
|
+
const registeredData = taxonomyRegistry.getRegisteredTaxonomy();
|
|
88
|
+
if (registeredData && registeredData.length > 0) {
|
|
89
|
+
taxonomyRegistry.log.debug('[Controller] getTaxonomyData - Late initializing from registry');
|
|
90
|
+
this.taxonomyData = registeredData;
|
|
91
|
+
this.popularItems = this.computePopularItems(this.taxonomyData);
|
|
92
|
+
this.searchEngine = new search_engine.TaxonomySearchEngine(this.taxonomyData);
|
|
93
|
+
}
|
|
94
|
+
return this.taxonomyData;
|
|
95
|
+
}
|
|
96
|
+
destroy() {
|
|
97
|
+
if (this.debounceTimer) {
|
|
98
|
+
clearTimeout(this.debounceTimer);
|
|
99
|
+
this.debounceTimer = undefined;
|
|
100
|
+
}
|
|
101
|
+
if (this.validationTimer) {
|
|
102
|
+
clearTimeout(this.validationTimer);
|
|
103
|
+
this.validationTimer = undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// --- INITIALIZATION ---
|
|
107
|
+
async initialize(initialData) {
|
|
108
|
+
taxonomyRegistry.log.debug('[Controller] initialize - START', { hasInitialData: !!initialData });
|
|
109
|
+
this.updateState({ status: 'loading' });
|
|
110
|
+
const registeredData = (initialData && initialData.length > 0) ? initialData : taxonomyRegistry.getRegisteredTaxonomy();
|
|
111
|
+
if (registeredData && registeredData.length > 0) {
|
|
112
|
+
taxonomyRegistry.log.debug('[Controller] initialize - Loaded taxonomy from registry');
|
|
113
|
+
this.setStateToActive(registeredData, 'registry', new Date().toISOString());
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
taxonomyRegistry.log.warn('[Controller] initialize - No taxonomy found in registry. Has registerTaxonomy() been called?');
|
|
117
|
+
this.updateState({ status: 'error', message: 'Kunde inte hitta yrkesdata. Kontrollera att komponenten är initierad korrekt.' });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
handleFocus() {
|
|
121
|
+
this.isTouched = true;
|
|
122
|
+
}
|
|
123
|
+
setStateToActive(data, source, sourceDate) {
|
|
124
|
+
this.taxonomyData = data;
|
|
125
|
+
this.popularItems = this.computePopularItems(this.taxonomyData);
|
|
126
|
+
// NEW: Initialize the separated SearchEngine
|
|
127
|
+
this.searchEngine = new search_engine.TaxonomySearchEngine(this.taxonomyData);
|
|
128
|
+
// OLD: this.fuseInstance = new Fuse(this.taxonomyData, FUSE_OPTIONS);
|
|
129
|
+
const commonState = { dataSource: source, dataSourceDate: sourceDate };
|
|
130
|
+
if (this.config.enableMultiple) {
|
|
131
|
+
this.updateState({
|
|
132
|
+
status: 'multiSelect',
|
|
133
|
+
inputValue: '',
|
|
134
|
+
selectedItems: [],
|
|
135
|
+
suggestions: [],
|
|
136
|
+
validation: 'idle',
|
|
137
|
+
...commonState
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.updateState({ status: 'searching', inputValue: '', suggestions: [], validation: 'idle', ...commonState });
|
|
142
|
+
}
|
|
143
|
+
// --- SEARCH ---
|
|
144
|
+
handleInput(value) {
|
|
145
|
+
this.isTouched = true;
|
|
146
|
+
taxonomyRegistry.log.debug('[Controller] handleInput - Value:', value);
|
|
147
|
+
// Reset validation on input
|
|
148
|
+
const wasInError = this.state.validation === 'error';
|
|
149
|
+
const validationContext = wasInError || this.state.validationContext === 'from-error' ? 'from-error' : undefined;
|
|
150
|
+
clearTimeout(this.debounceTimer);
|
|
151
|
+
if (value.trim() === '') {
|
|
152
|
+
if (this.config.enableMultiple) {
|
|
153
|
+
const currentSelected = 'selectedItems' in this.state ? this.state.selectedItems : [];
|
|
154
|
+
this.updateState({
|
|
155
|
+
status: 'multiSelect',
|
|
156
|
+
inputValue: '',
|
|
157
|
+
selectedItems: currentSelected,
|
|
158
|
+
suggestions: [],
|
|
159
|
+
validation: 'idle',
|
|
160
|
+
validationContext, // BEVARA CONTEXT
|
|
161
|
+
...this.getCommonState()
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
this.updateState({
|
|
166
|
+
status: 'searching',
|
|
167
|
+
inputValue: '',
|
|
168
|
+
suggestions: [],
|
|
169
|
+
validation: 'idle',
|
|
170
|
+
validationContext, // BEVARA CONTEXT
|
|
171
|
+
...this.getCommonState()
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
this.showHistory();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
switch (this.state.status) {
|
|
178
|
+
case 'multiSelect':
|
|
179
|
+
case 'multiSelectShowingHistory': {
|
|
180
|
+
const selectedItems = 'selectedItems' in this.state ? this.state.selectedItems : [];
|
|
181
|
+
this.updateState({
|
|
182
|
+
status: 'multiSelect',
|
|
183
|
+
inputValue: value,
|
|
184
|
+
selectedItems,
|
|
185
|
+
suggestions: 'suggestions' in this.state ? this.state.suggestions : ('historyItems' in this.state ? this.state.historyItems : []),
|
|
186
|
+
validation: 'idle',
|
|
187
|
+
validationContext,
|
|
188
|
+
dataSource: 'dataSource' in this.state ? this.state.dataSource : undefined,
|
|
189
|
+
dataSourceDate: 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined
|
|
190
|
+
});
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case 'itemSelected':
|
|
194
|
+
case 'searching':
|
|
195
|
+
case 'showingHistory': {
|
|
196
|
+
this.updateState({
|
|
197
|
+
status: 'searching',
|
|
198
|
+
inputValue: value,
|
|
199
|
+
suggestions: 'suggestions' in this.state ? this.state.suggestions : ('historyItems' in this.state ? this.state.historyItems : []),
|
|
200
|
+
validation: 'idle',
|
|
201
|
+
validationContext,
|
|
202
|
+
dataSource: 'dataSource' in this.state ? this.state.dataSource : undefined,
|
|
203
|
+
dataSourceDate: 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined
|
|
204
|
+
});
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case 'loading':
|
|
208
|
+
case 'error':
|
|
209
|
+
return; // Interaction not allowed or ignored
|
|
210
|
+
default: {
|
|
211
|
+
const _exhaustive = this.state;
|
|
212
|
+
return _exhaustive;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (value.length >= this.config.minLength) {
|
|
216
|
+
this.debounceTimer = setTimeout(() => this.performSearch(value), search_engine.DEBOUNCE_TIME);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
performSearch(value) {
|
|
220
|
+
if (!this.searchEngine)
|
|
221
|
+
return;
|
|
222
|
+
switch (this.state.status) {
|
|
223
|
+
case 'searching':
|
|
224
|
+
case 'multiSelect': {
|
|
225
|
+
const genericResults = this.searchEngine.searchAsGeneric(value, this.config.maxResults);
|
|
226
|
+
const results = genericResults.flatMap(g => g.metadata?.originalResult ? [g.metadata.originalResult] : []);
|
|
227
|
+
// Do not filter out selected items here - allow UI to handle grey-out
|
|
228
|
+
this.updateState({ ...this.state, suggestions: results });
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case 'loading':
|
|
232
|
+
case 'error':
|
|
233
|
+
case 'itemSelected':
|
|
234
|
+
case 'showingHistory':
|
|
235
|
+
case 'multiSelectShowingHistory':
|
|
236
|
+
return; // Nothing to search from here
|
|
237
|
+
default: {
|
|
238
|
+
const _exhaustive = this.state;
|
|
239
|
+
return _exhaustive;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// --- HISTORY HANDLING ---
|
|
244
|
+
addToHistory(item) {
|
|
245
|
+
if (!this.config.history)
|
|
246
|
+
return;
|
|
247
|
+
// Reload history from cache to ensure we have the latest state from other instances
|
|
248
|
+
const historyItems = HistoryService.loadHistory();
|
|
249
|
+
// Clone item and reset highlightedLabel to displayLabel (removes bolding)
|
|
250
|
+
// AND apply grey styling to parenthesis (via DisplaySegments)
|
|
251
|
+
const unhighlightedSegments = search_engine.LabelFormatter.format(item.rawLabel, '', item.occupationLabel);
|
|
252
|
+
const historyItem = {
|
|
253
|
+
...item,
|
|
254
|
+
displaySegments: unhighlightedSegments,
|
|
255
|
+
// Legacy support: construct HTML string for fallback
|
|
256
|
+
highlightedLabel: search_engine.segmentsToHtml(unhighlightedSegments)
|
|
257
|
+
};
|
|
258
|
+
// Filter out existing item with same ID (to move it to top)
|
|
259
|
+
const filtered = historyItems.filter((h) => {
|
|
260
|
+
if (h.id && historyItem.id)
|
|
261
|
+
return h.id !== historyItem.id;
|
|
262
|
+
return h.rawLabel !== historyItem.rawLabel;
|
|
263
|
+
});
|
|
264
|
+
// Add to top and limit
|
|
265
|
+
const newHistory = [historyItem, ...filtered].slice(0, this.config.maxHistoryItems);
|
|
266
|
+
HistoryService.saveHistory(newHistory);
|
|
267
|
+
}
|
|
268
|
+
clearHistory() {
|
|
269
|
+
HistoryService.clearHistory();
|
|
270
|
+
this.showHistory(true);
|
|
271
|
+
}
|
|
272
|
+
computePopularItems(data, limit = this.config.maxResults || search_engine.MAX_RESULTS) {
|
|
273
|
+
if (!data || data.length === 0)
|
|
274
|
+
return [];
|
|
275
|
+
const hasWeights = data.some(t => typeof t.weight === 'number' && t.weight > 0);
|
|
276
|
+
if (!hasWeights) {
|
|
277
|
+
return data.slice(0, limit);
|
|
278
|
+
}
|
|
279
|
+
const sorted = [...data].sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0));
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
const popular = [];
|
|
282
|
+
for (const item of sorted) {
|
|
283
|
+
const key = item.occupation_name_preferred_label || item.preferred_label;
|
|
284
|
+
if (!seen.has(key)) {
|
|
285
|
+
seen.add(key);
|
|
286
|
+
popular.push(item);
|
|
287
|
+
}
|
|
288
|
+
if (popular.length === limit)
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
return popular;
|
|
292
|
+
}
|
|
293
|
+
showHistory(force = false) {
|
|
294
|
+
if (this.state.status === 'loading' || this.state.status === 'error')
|
|
295
|
+
return;
|
|
296
|
+
if (!this.config.history)
|
|
297
|
+
return;
|
|
298
|
+
const historyItems = HistoryService.loadHistory();
|
|
299
|
+
let displayItems = historyItems;
|
|
300
|
+
let isPopular = false;
|
|
301
|
+
if (historyItems.length === 0) {
|
|
302
|
+
if (this.config.popular === false) {
|
|
303
|
+
displayItems = [];
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
if (this.popularItems.length === 0 && this.taxonomyData.length > 0) {
|
|
307
|
+
this.popularItems = this.computePopularItems(this.taxonomyData);
|
|
308
|
+
}
|
|
309
|
+
// Provide some popular items if history is empty (and we have taxonomy data)
|
|
310
|
+
displayItems = this.popularItems.map(t => this.toSearchResult(t));
|
|
311
|
+
isPopular = true;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Lazy Validation: Filter items against current taxonomy and config
|
|
315
|
+
const validatedHistory = displayItems.filter((item) => {
|
|
316
|
+
// 1. Check if it's a ManualInput item
|
|
317
|
+
if (item.type === 'ManualInput') {
|
|
318
|
+
return !this.config.requireSelection;
|
|
319
|
+
}
|
|
320
|
+
// 2. Check if it's a valid Taxonomy item (must exist in current data)
|
|
321
|
+
// Note: We use this.taxonomyData which is already loaded
|
|
322
|
+
const exists = this.taxonomyData.some(t => t.id === item.id);
|
|
323
|
+
return exists;
|
|
324
|
+
});
|
|
325
|
+
const filteredHistory = validatedHistory.map((item) => {
|
|
326
|
+
const unhighlightedSegments = search_engine.LabelFormatter.format(item.rawLabel, '', item.occupationLabel);
|
|
327
|
+
return {
|
|
328
|
+
...item,
|
|
329
|
+
displaySegments: unhighlightedSegments,
|
|
330
|
+
highlightedLabel: search_engine.segmentsToHtml(unhighlightedSegments)
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
const { dataSource, dataSourceDate } = this.getCommonState();
|
|
334
|
+
const currentInputValue = 'inputValue' in this.state ? this.state.inputValue : '';
|
|
335
|
+
// If we have input text, we don't want to show history unless explicitly in a history state
|
|
336
|
+
// OR if we are forcing it (e.g. from editSelection)
|
|
337
|
+
if (!force && currentInputValue.trim() !== '' && this.state.status !== 'showingHistory' && this.state.status !== 'multiSelectShowingHistory') {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (this.config.enableMultiple) {
|
|
341
|
+
const selectedItems = 'selectedItems' in this.state ? this.state.selectedItems : [];
|
|
342
|
+
const wasInError = this.state.validation === 'error' || this.state.validationContext === 'from-error';
|
|
343
|
+
const validationContext = wasInError ? 'from-error' : undefined;
|
|
344
|
+
const validation = 'validation' in this.state ? this.state.validation : 'idle';
|
|
345
|
+
this.updateState({
|
|
346
|
+
status: 'multiSelectShowingHistory',
|
|
347
|
+
selectedItems,
|
|
348
|
+
historyItems: filteredHistory,
|
|
349
|
+
inputValue: currentInputValue,
|
|
350
|
+
validation,
|
|
351
|
+
validationContext,
|
|
352
|
+
dataSource,
|
|
353
|
+
dataSourceDate,
|
|
354
|
+
isPopularView: isPopular // Flag for view
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
const wasInError = this.state.validation === 'error' || this.state.validationContext === 'from-error';
|
|
359
|
+
const validationContext = wasInError ? 'from-error' : undefined;
|
|
360
|
+
const validation = 'validation' in this.state ? this.state.validation : 'idle';
|
|
361
|
+
this.updateState({
|
|
362
|
+
status: 'showingHistory',
|
|
363
|
+
historyItems: filteredHistory,
|
|
364
|
+
inputValue: currentInputValue,
|
|
365
|
+
validation,
|
|
366
|
+
validationContext,
|
|
367
|
+
dataSource,
|
|
368
|
+
dataSourceDate,
|
|
369
|
+
isPopularView: isPopular // Flag for view
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// --- SELECTION ---
|
|
374
|
+
selectItem(item) {
|
|
375
|
+
taxonomyRegistry.log.debug('[Controller] selectItem', item);
|
|
376
|
+
// Add to history
|
|
377
|
+
this.isTouched = true;
|
|
378
|
+
this.addToHistory(item);
|
|
379
|
+
// Standardized Success Message Logic:
|
|
380
|
+
// Check for error state before update to allow transition to success.
|
|
381
|
+
// This applies to ALL selection events (Single, Multi, History, Search).
|
|
382
|
+
const wasInError = this.state.validation === 'error' || this.state.validationContext === 'from-error';
|
|
383
|
+
const nextValidationContext = wasInError ? 'from-error' : undefined;
|
|
384
|
+
switch (this.state.status) {
|
|
385
|
+
case 'multiSelect':
|
|
386
|
+
case 'multiSelectShowingHistory': {
|
|
387
|
+
const dataSource = 'dataSource' in this.state ? this.state.dataSource : undefined;
|
|
388
|
+
const dataSourceDate = 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined;
|
|
389
|
+
const currentSelected = 'selectedItems' in this.state ? this.state.selectedItems : [];
|
|
390
|
+
// Prevent duplicates
|
|
391
|
+
const existingIndex = currentSelected.findIndex(existing => {
|
|
392
|
+
if (existing.id && item.id)
|
|
393
|
+
return existing.id === item.id;
|
|
394
|
+
return existing.rawLabel === item.rawLabel;
|
|
395
|
+
});
|
|
396
|
+
if (existingIndex > -1) {
|
|
397
|
+
// Already selected - skip to avoid double addition or accidental removal in this context
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const newItems = [...currentSelected, item];
|
|
401
|
+
// Uppdatera state: rensa input och förslag
|
|
402
|
+
this.updateState({
|
|
403
|
+
...this.state,
|
|
404
|
+
status: 'multiSelect',
|
|
405
|
+
selectedItems: newItems,
|
|
406
|
+
inputValue: '',
|
|
407
|
+
suggestions: [],
|
|
408
|
+
validation: 'idle',
|
|
409
|
+
validationContext: nextValidationContext, // Use centralized context
|
|
410
|
+
dataSource,
|
|
411
|
+
dataSourceDate
|
|
412
|
+
});
|
|
413
|
+
this.callbacks.onEmitSelection(newItems.map(i => this.toPublicSelection(i)));
|
|
414
|
+
this.showHistory(true);
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
case 'searching':
|
|
418
|
+
case 'showingHistory': {
|
|
419
|
+
const dataSource = 'dataSource' in this.state ? this.state.dataSource : undefined;
|
|
420
|
+
const dataSourceDate = 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined;
|
|
421
|
+
this.updateState({
|
|
422
|
+
status: 'itemSelected',
|
|
423
|
+
selectedItem: item,
|
|
424
|
+
inputValue: item.preferred_label, // Keep label as text
|
|
425
|
+
validation: 'idle',
|
|
426
|
+
validationContext: nextValidationContext, // Use centralized context
|
|
427
|
+
dataSource,
|
|
428
|
+
dataSourceDate,
|
|
429
|
+
});
|
|
430
|
+
this.callbacks.onEmitSelection(this.toPublicSelection(item));
|
|
431
|
+
if (this.config.enableMultiple) {
|
|
432
|
+
this.showHistory(true);
|
|
433
|
+
}
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
case 'loading':
|
|
437
|
+
case 'error':
|
|
438
|
+
// Do nothing in these states
|
|
439
|
+
break;
|
|
440
|
+
case 'itemSelected': {
|
|
441
|
+
const dataSource = 'dataSource' in this.state ? this.state.dataSource : undefined;
|
|
442
|
+
const dataSourceDate = 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined;
|
|
443
|
+
this.updateState({
|
|
444
|
+
...this.state,
|
|
445
|
+
status: 'itemSelected',
|
|
446
|
+
selectedItem: item,
|
|
447
|
+
inputValue: item.preferred_label,
|
|
448
|
+
validation: 'idle',
|
|
449
|
+
validationContext: nextValidationContext,
|
|
450
|
+
dataSource,
|
|
451
|
+
dataSourceDate,
|
|
452
|
+
});
|
|
453
|
+
this.callbacks.onEmitSelection(this.toPublicSelection(item));
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
default: {
|
|
457
|
+
const _exhaustive = this.state;
|
|
458
|
+
taxonomyRegistry.log.warn('[Controller] selectItem - Unexpected state:', _exhaustive);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
this.runValidation();
|
|
462
|
+
}
|
|
463
|
+
clearSelection() {
|
|
464
|
+
if (this.state.status !== 'itemSelected')
|
|
465
|
+
return;
|
|
466
|
+
this.isTouched = true;
|
|
467
|
+
const currentInputValue = this.state.inputValue || '';
|
|
468
|
+
this.updateState({
|
|
469
|
+
status: 'searching',
|
|
470
|
+
inputValue: currentInputValue,
|
|
471
|
+
suggestions: [],
|
|
472
|
+
validation: 'idle',
|
|
473
|
+
...this.getCommonState()
|
|
474
|
+
});
|
|
475
|
+
this.callbacks.onEmitSelection([]); // Empty array instead of null
|
|
476
|
+
// Try to show history if enabled
|
|
477
|
+
this.showHistory();
|
|
478
|
+
this.runValidation();
|
|
479
|
+
}
|
|
480
|
+
removeItem(item) {
|
|
481
|
+
switch (this.state.status) {
|
|
482
|
+
case 'multiSelect':
|
|
483
|
+
case 'multiSelectShowingHistory': {
|
|
484
|
+
const newItems = this.state.selectedItems.filter(i => {
|
|
485
|
+
if (i.id && item.id)
|
|
486
|
+
return i.id !== item.id;
|
|
487
|
+
return i.rawLabel !== item.rawLabel;
|
|
488
|
+
});
|
|
489
|
+
this.isTouched = true;
|
|
490
|
+
this.updateState({ ...this.state, selectedItems: newItems });
|
|
491
|
+
this.callbacks.onEmitSelection(newItems.map(i => this.toPublicSelection(i)));
|
|
492
|
+
// Try to show history if enabled (since we are likely still focused)
|
|
493
|
+
const inputValue = 'inputValue' in this.state ? this.state.inputValue : '';
|
|
494
|
+
if (inputValue === '') {
|
|
495
|
+
this.showHistory();
|
|
496
|
+
}
|
|
497
|
+
this.runValidation();
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// --- BLUR & VALIDATION ---
|
|
503
|
+
handleBlur() {
|
|
504
|
+
this.isTouched = true;
|
|
505
|
+
if (this.state.status === 'searching') {
|
|
506
|
+
const trimmedValue = (this.state.inputValue || '').trim();
|
|
507
|
+
if (trimmedValue !== '') {
|
|
508
|
+
const exactMatch = this.findExactMatch(trimmedValue);
|
|
509
|
+
if (exactMatch) {
|
|
510
|
+
this.selectItem(this.toSearchResult(exactMatch));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// In TEA/FSM, handleBlur should focus on state transitions and validation,
|
|
516
|
+
// rather than side-effects like clearing UI data.
|
|
517
|
+
// We transition back to primary states (searching/multiSelect) but KEEP suggestions.
|
|
518
|
+
switch (this.state.status) {
|
|
519
|
+
case 'searching':
|
|
520
|
+
case 'showingHistory':
|
|
521
|
+
this.updateState({
|
|
522
|
+
...this.state,
|
|
523
|
+
status: 'searching',
|
|
524
|
+
inputValue: 'inputValue' in this.state ? this.state.inputValue : '',
|
|
525
|
+
suggestions: []
|
|
526
|
+
});
|
|
527
|
+
break;
|
|
528
|
+
case 'multiSelect':
|
|
529
|
+
case 'multiSelectShowingHistory': {
|
|
530
|
+
this.updateState({
|
|
531
|
+
...this.state,
|
|
532
|
+
status: 'multiSelect',
|
|
533
|
+
inputValue: 'inputValue' in this.state ? this.state.inputValue : '',
|
|
534
|
+
suggestions: []
|
|
535
|
+
});
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
case 'loading':
|
|
539
|
+
case 'error':
|
|
540
|
+
case 'itemSelected':
|
|
541
|
+
break;
|
|
542
|
+
default: {
|
|
543
|
+
const _exhaustive = this.state;
|
|
544
|
+
return _exhaustive;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
this.runValidation();
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Validerar nuvarande värde och sätter eventuella valideringsfel.
|
|
551
|
+
* @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.
|
|
552
|
+
*/
|
|
553
|
+
runValidation(forceTouch = false) {
|
|
554
|
+
if (forceTouch) {
|
|
555
|
+
this.isTouched = true;
|
|
556
|
+
}
|
|
557
|
+
if (!this.isTouched)
|
|
558
|
+
return true;
|
|
559
|
+
if (this.state.status === 'loading' || this.state.status === 'error')
|
|
560
|
+
return true;
|
|
561
|
+
const hasValue = this.checkHasValue();
|
|
562
|
+
let isValid = true;
|
|
563
|
+
let newErrorType = null;
|
|
564
|
+
// Check for invalid input (text that doesn't match a selection)
|
|
565
|
+
// This applies in both single 'searching' mode and 'multiSelect' mode
|
|
566
|
+
const isSearchingOrMulti = this.state.status === 'searching' || this.state.status === 'multiSelect' || this.state.status === 'multiSelectShowingHistory';
|
|
567
|
+
const inputValue = 'inputValue' in this.state ? this.state.inputValue : '';
|
|
568
|
+
const hasInputText = inputValue.trim() !== '';
|
|
569
|
+
if (isSearchingOrMulti && hasInputText) {
|
|
570
|
+
// New rule: If there is text in the box that isn't confirmed as a selection, it's ALWAYS an error
|
|
571
|
+
// regardless of whether manual input is enabled or not.
|
|
572
|
+
// The user MUST confirm by selecting the manual input option.
|
|
573
|
+
isValid = false;
|
|
574
|
+
newErrorType = 'invalidInput';
|
|
575
|
+
}
|
|
576
|
+
else if (this.config.required && !hasValue) {
|
|
577
|
+
isValid = false;
|
|
578
|
+
newErrorType = 'required';
|
|
579
|
+
}
|
|
580
|
+
this.activeErrorType = newErrorType;
|
|
581
|
+
// Strict FSM for validation state
|
|
582
|
+
// Rules:
|
|
583
|
+
// 1. Error only if invalid.
|
|
584
|
+
// 2. Idle if valid (never use success to avoid aggressive green checks).
|
|
585
|
+
// Valid state: only show success if recovering from an error (Red -> Green pattern) AND we actually have a value.
|
|
586
|
+
const wasInError = this.state.validation === 'error' || this.state.validationContext === 'from-error';
|
|
587
|
+
let validationState;
|
|
588
|
+
if (!isValid) {
|
|
589
|
+
validationState = newErrorType === 'invalidInput' || forceTouch || wasInError ? 'error' : 'idle';
|
|
590
|
+
}
|
|
591
|
+
else if (wasInError && hasValue) {
|
|
592
|
+
validationState = 'success';
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
validationState = 'idle';
|
|
596
|
+
}
|
|
597
|
+
if (validationState === 'success') {
|
|
598
|
+
clearTimeout(this.validationTimer);
|
|
599
|
+
this.validationTimer = setTimeout(() => {
|
|
600
|
+
// Only clear if still success (user hasn't messed up again)
|
|
601
|
+
const isSuccess = 'validation' in this.state && this.state.validation === 'success';
|
|
602
|
+
const allowedStatus = ['itemSelected', 'multiSelect', 'multiSelectShowingHistory'].includes(this.state.status);
|
|
603
|
+
if (allowedStatus && isSuccess) {
|
|
604
|
+
this.updateState({ ...this.state, validation: 'idle', validationContext: undefined });
|
|
605
|
+
}
|
|
606
|
+
this.validationTimer = undefined;
|
|
607
|
+
}, 3000);
|
|
608
|
+
}
|
|
609
|
+
// Update state based on current status (respecting type definitions)
|
|
610
|
+
switch (this.state.status) {
|
|
611
|
+
case 'searching':
|
|
612
|
+
this.updateState({ ...this.state, validation: validationState });
|
|
613
|
+
break;
|
|
614
|
+
case 'showingHistory':
|
|
615
|
+
this.updateState({ ...this.state, validation: validationState });
|
|
616
|
+
break;
|
|
617
|
+
case 'multiSelect':
|
|
618
|
+
// Allow error AND success states for multiSelect
|
|
619
|
+
this.updateState({ ...this.state, validation: validationState });
|
|
620
|
+
break;
|
|
621
|
+
case 'multiSelectShowingHistory':
|
|
622
|
+
this.updateState({ ...this.state, validation: validationState });
|
|
623
|
+
break;
|
|
624
|
+
case 'itemSelected':
|
|
625
|
+
// ItemSelected does not support 'error'. But checkHasValue() returns true, so isValid is true.
|
|
626
|
+
if (validationState !== 'error') {
|
|
627
|
+
this.updateState({ ...this.state, validation: validationState });
|
|
628
|
+
}
|
|
629
|
+
break;
|
|
630
|
+
default: {
|
|
631
|
+
// If we reach here, we must have handled all states that aren't 'loading' or 'error'
|
|
632
|
+
// But TypeScript needs to know that.
|
|
633
|
+
const _exhaustive = this.state;
|
|
634
|
+
return _exhaustive;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
this.callbacks.onEmitValidation({ isValid, errorType: newErrorType });
|
|
638
|
+
return isValid;
|
|
639
|
+
}
|
|
640
|
+
selectItemById(id) {
|
|
641
|
+
if (!id || this.config.enableMultiple || !this.taxonomyData)
|
|
642
|
+
return;
|
|
643
|
+
const itemToSelect = this.taxonomyData.find(t => t.id === id);
|
|
644
|
+
if (itemToSelect) {
|
|
645
|
+
const searchResult = this.toSearchResult(itemToSelect);
|
|
646
|
+
const dataSource = 'dataSource' in this.state ? this.state.dataSource : undefined;
|
|
647
|
+
const dataSourceDate = 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined;
|
|
648
|
+
this.updateState({
|
|
649
|
+
status: 'itemSelected',
|
|
650
|
+
selectedItem: searchResult,
|
|
651
|
+
inputValue: searchResult.preferred_label,
|
|
652
|
+
validation: 'idle',
|
|
653
|
+
dataSource,
|
|
654
|
+
dataSourceDate,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
this.runValidation();
|
|
658
|
+
}
|
|
659
|
+
selectItemsByIds(ids) {
|
|
660
|
+
if (!ids || !this.config.enableMultiple || !this.taxonomyData)
|
|
661
|
+
return;
|
|
662
|
+
const taxonomyItems = ids.map(id => this.taxonomyData.find(item => item.id === id));
|
|
663
|
+
if (taxonomyItems.some((item) => !item))
|
|
664
|
+
return;
|
|
665
|
+
const newSelectedItems = taxonomyItems
|
|
666
|
+
.filter((item) => !!item)
|
|
667
|
+
.map(item => this.toSearchResult(item));
|
|
668
|
+
switch (this.state.status) {
|
|
669
|
+
case 'multiSelect':
|
|
670
|
+
case 'multiSelectShowingHistory':
|
|
671
|
+
this.updateState({
|
|
672
|
+
...this.state,
|
|
673
|
+
selectedItems: newSelectedItems
|
|
674
|
+
});
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
this.runValidation();
|
|
678
|
+
}
|
|
679
|
+
setSelection(selection) {
|
|
680
|
+
const invalidIds = [];
|
|
681
|
+
const selectedItems = selection.map((item) => {
|
|
682
|
+
if (item.id !== null) {
|
|
683
|
+
const taxonomyItem = this.taxonomyData.find((taxonomy) => taxonomy.id === item.id);
|
|
684
|
+
if (!taxonomyItem) {
|
|
685
|
+
invalidIds.push(item.id);
|
|
686
|
+
return undefined;
|
|
687
|
+
}
|
|
688
|
+
return this.toSearchResult(taxonomyItem);
|
|
689
|
+
}
|
|
690
|
+
return this.createManualInputItem(item.preferred_label);
|
|
691
|
+
});
|
|
692
|
+
if (invalidIds.length > 0) {
|
|
693
|
+
return { ok: false, invalidIds };
|
|
694
|
+
}
|
|
695
|
+
const resolvedItems = selectedItems;
|
|
696
|
+
const commonState = this.getCommonState();
|
|
697
|
+
if (this.config.enableMultiple) {
|
|
698
|
+
this.updateState({
|
|
699
|
+
...this.state,
|
|
700
|
+
status: 'multiSelect',
|
|
701
|
+
selectedItems: resolvedItems,
|
|
702
|
+
inputValue: '',
|
|
703
|
+
suggestions: [],
|
|
704
|
+
validation: 'idle',
|
|
705
|
+
...commonState
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
else if (resolvedItems.length === 1) {
|
|
709
|
+
const selectedItem = resolvedItems[0];
|
|
710
|
+
this.updateState({
|
|
711
|
+
status: 'itemSelected',
|
|
712
|
+
selectedItem,
|
|
713
|
+
inputValue: selectedItem.preferred_label,
|
|
714
|
+
validation: 'idle',
|
|
715
|
+
...commonState
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
this.updateState({
|
|
720
|
+
status: 'searching',
|
|
721
|
+
inputValue: '',
|
|
722
|
+
suggestions: [],
|
|
723
|
+
validation: 'idle',
|
|
724
|
+
...commonState
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
this.runValidation();
|
|
728
|
+
return { ok: true, selection: resolvedItems.map((item) => this.toPublicSelection(item)) };
|
|
729
|
+
}
|
|
730
|
+
editSelection() {
|
|
731
|
+
const selectedItem = this.state.selectedItem;
|
|
732
|
+
if (this.state.status !== 'itemSelected' || !selectedItem || !this.searchEngine)
|
|
733
|
+
return;
|
|
734
|
+
// Transition to searching first to ensure input shows up
|
|
735
|
+
const professionLabel = selectedItem.displayLabel;
|
|
736
|
+
const { dataSource, dataSourceDate } = this.state;
|
|
737
|
+
// Rule: On activation, show search results for the current string instead of history
|
|
738
|
+
const genericResults = this.searchEngine.searchAsGeneric(professionLabel, this.config.maxResults);
|
|
739
|
+
const searchResults = genericResults.flatMap(g => g.metadata?.originalResult ? [g.metadata.originalResult] : []);
|
|
740
|
+
this.updateState({
|
|
741
|
+
status: 'searching',
|
|
742
|
+
inputValue: professionLabel,
|
|
743
|
+
suggestions: searchResults,
|
|
744
|
+
validation: 'idle',
|
|
745
|
+
dataSource,
|
|
746
|
+
dataSourceDate
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
checkHasValue() {
|
|
750
|
+
switch (this.state.status) {
|
|
751
|
+
case 'itemSelected':
|
|
752
|
+
return true;
|
|
753
|
+
case 'multiSelect':
|
|
754
|
+
case 'multiSelectShowingHistory':
|
|
755
|
+
return this.state.selectedItems.length > 0;
|
|
756
|
+
case 'searching':
|
|
757
|
+
case 'showingHistory': {
|
|
758
|
+
const inputValue = 'inputValue' in this.state ? this.state.inputValue : '';
|
|
759
|
+
return !this.config.requireSelection && inputValue.trim() !== '';
|
|
760
|
+
}
|
|
761
|
+
case 'loading':
|
|
762
|
+
case 'error':
|
|
763
|
+
return false;
|
|
764
|
+
default: {
|
|
765
|
+
const _exhaustive = this.state;
|
|
766
|
+
return _exhaustive;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
getCommonState() {
|
|
771
|
+
return {
|
|
772
|
+
dataSource: 'dataSource' in this.state ? this.state.dataSource : undefined,
|
|
773
|
+
dataSourceDate: 'dataSourceDate' in this.state ? this.state.dataSourceDate : undefined
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
resetMultiSelection() {
|
|
777
|
+
const commonState = this.getCommonState();
|
|
778
|
+
this.updateState({
|
|
779
|
+
...this.state,
|
|
780
|
+
status: 'multiSelect',
|
|
781
|
+
selectedItems: [],
|
|
782
|
+
inputValue: '',
|
|
783
|
+
suggestions: [],
|
|
784
|
+
validation: 'idle',
|
|
785
|
+
...commonState
|
|
786
|
+
});
|
|
787
|
+
this.callbacks.onEmitSelection([]);
|
|
788
|
+
}
|
|
789
|
+
// --- HELPERS ---
|
|
790
|
+
updateState(newState) {
|
|
791
|
+
this.state = newState;
|
|
792
|
+
this.callbacks.onStateChange(newState);
|
|
793
|
+
}
|
|
794
|
+
toSearchResult(item) {
|
|
795
|
+
return {
|
|
796
|
+
...item,
|
|
797
|
+
rawLabel: item.preferred_label,
|
|
798
|
+
displayLabel: item.occupation_name_preferred_label
|
|
799
|
+
? `${item.preferred_label} (${item.occupation_name_preferred_label})`
|
|
800
|
+
: item.preferred_label,
|
|
801
|
+
highlightedLabel: item.preferred_label,
|
|
802
|
+
score: 3.0,
|
|
803
|
+
// Map v2 fields to match SearchResult interface expectations if needed
|
|
804
|
+
occupationLabel: item.occupation_name_preferred_label,
|
|
805
|
+
occupationId: item.occupation_name_id,
|
|
806
|
+
ssyk_level_4_id: item.ssyk_level_4_id,
|
|
807
|
+
ssyk_code_2012: item.ssyk_code_2012
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
toPublicSelection(item) {
|
|
811
|
+
return search_engine.TaxonomyAdapter.toPublicSelection(item);
|
|
812
|
+
}
|
|
813
|
+
createManualInputItem(value) {
|
|
814
|
+
return {
|
|
815
|
+
id: '',
|
|
816
|
+
type: 'ManualInput',
|
|
817
|
+
preferred_label: value,
|
|
818
|
+
rawLabel: value,
|
|
819
|
+
displayLabel: value,
|
|
820
|
+
highlightedLabel: value,
|
|
821
|
+
score: 1.0
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
findExactMatch(value) {
|
|
825
|
+
return this.taxonomyData.find(item => {
|
|
826
|
+
const displayLabel = item.occupation_name_preferred_label
|
|
827
|
+
? `${item.preferred_label} (${item.occupation_name_preferred_label})`
|
|
828
|
+
: item.preferred_label;
|
|
829
|
+
return displayLabel.toLowerCase() === value.toLowerCase() || item.preferred_label.toLowerCase() === value.toLowerCase();
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
// --- FREETEXT HANDLING (used by keyboard handler) ---
|
|
833
|
+
handleFreeTextSelection(value) {
|
|
834
|
+
if (!value)
|
|
835
|
+
return;
|
|
836
|
+
this.isTouched = true;
|
|
837
|
+
const exactMatch = this.findExactMatch(value);
|
|
838
|
+
if (exactMatch) {
|
|
839
|
+
this.selectItem(this.toSearchResult(exactMatch));
|
|
840
|
+
}
|
|
841
|
+
else {
|
|
842
|
+
this.selectItem(this.createManualInputItem(value));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
loadMore() {
|
|
846
|
+
// Increment maxResults or handle paging if supported by searchEngine
|
|
847
|
+
this.config.maxResults += 10;
|
|
848
|
+
const inputValue = 'inputValue' in this.state ? this.state.inputValue : '';
|
|
849
|
+
if (inputValue) {
|
|
850
|
+
this.performSearch(inputValue);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
resetSearch() {
|
|
854
|
+
switch (this.state.status) {
|
|
855
|
+
case 'searching':
|
|
856
|
+
case 'multiSelect':
|
|
857
|
+
this.updateState({ ...this.state, inputValue: '', suggestions: [] });
|
|
858
|
+
this.showHistory(true);
|
|
859
|
+
break;
|
|
860
|
+
case 'showingHistory':
|
|
861
|
+
case 'multiSelectShowingHistory':
|
|
862
|
+
// Already showing history, just ensure input is empty
|
|
863
|
+
this.updateState({ ...this.state, inputValue: '' });
|
|
864
|
+
break;
|
|
865
|
+
case 'itemSelected':
|
|
866
|
+
this.clearSelection();
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const jobSelectorCss = () => `:host{--digi--grayscale-0:#ffffff;--digi--grayscale-50:#fbfbfb;--digi--grayscale-100:#f4f4f4;--digi--grayscale-200:#ededed;--digi--grayscale-300:#e4e4e4;--digi--grayscale-400:#dddddd;--digi--grayscale-500:#cacaca;--digi--grayscale-600:#acacac;--digi--grayscale-700:#757575;--digi--grayscale-800:#5c5c5c;--digi--grayscale-900:#333333;--digi--grayscale-1000:#000000;--digi--global--color--cta--link--base:#1616b2;--digi--global--color--function--danger--base:#da0000;--digi--global--color--function--success--base:#008a20;--digi--global--color--function--success--lightest-3:#d4edda;--digi--global--color--function--warning--base:#ffe200;--digi--color--text--primary:var(--digi--grayscale-900);--digi--color--text--secondary:var(--digi--grayscale-700);--digi--color--text--link:var(--digi--global--color--cta--link--base);--digi--color--icons--success:var(--digi--global--color--function--success--base);--digi--color--icons--warning:#8a7a00;--digi--color--icons--danger:var(--digi--global--color--function--danger--base);--digi--global--typography--font-family--default:"Open Sans", Arial, sans-serif;--digi--global--typography--font-size--base:1rem;--digi--global--typography--font-size--small:0.875rem;--digi--global--typography--font-weight--regular:400;--digi--global--typography--font-weight--semibold:600;--digi--global--typography--font-weight--bold:700;--digi--global--typography--line-height--base:1.5;--digi--typography--label--font-size--desktop:1rem;--digi--typography--label--font-weight--desktop:600;--digi--typography--label--line-height--desktop:1.5;--digi--typography--label-description--font-size--desktop:1rem;--digi--typography--label-description--line-height--desktop:1.5;--digi--typography--tag--font-size--desktop:0.875rem;--digi--gutter--icon:0.625rem;--digi--gutter--smaller:0.75rem;--digi--gutter--small:0.875rem;--digi--gutter--medium:1rem;--digi--border-width--primary:1px;--digi--border-style--primary:solid;--digi--border-radius--primary:0;--digi--border-radius--secondary:6px;--af-tag-color:#091d54;--af-tag-hover-background:#ddf1fc}.af-form-label{color:var(--digi--color--text--primary);display:flex;flex-direction:column;font-family:var(--digi--global--typography--font-family--default);font-size:var(--digi--typography--label--font-size--desktop);margin-bottom:0.5rem}.af-form-label__label{cursor:pointer;display:block;font-weight:var(--digi--typography--label--font-weight--desktop);line-height:var(--digi--typography--label--line-height--desktop)}.af-form-label__description{display:block;font-size:var(--digi--typography--label-description--font-size--desktop);line-height:var(--digi--typography--label-description--line-height--desktop);max-width:35rem}.af-validation-message{align-items:center;display:flex;flex-wrap:wrap}.af-validation-message__icon{flex:0 0 auto;height:1.25em;margin-inline-end:var(--digi--gutter--icon);width:1.25em}.af-validation-message__icon--success,.af-validation-message__icon--error{background-color:var(--af-validation-icon-color, currentColor);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain}.af-validation-message__icon--success{--af-validation-icon-color:var(--digi--color--icons--success);-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25 25'%3E%3Cpath d='M25 12.5C25 19.404 19.404 25 12.5 25S0 19.404 0 12.5 5.596 0 12.5 0 25 5.596 25 12.5zm-13.946 6.619l9.274-9.275a.806.806 0 000-1.14l-1.14-1.14a.806.806 0 00-1.14 0l-7.564 7.563-3.531-3.531a.807.807 0 00-1.14 0l-1.141 1.14a.806.806 0 000 1.14l5.242 5.243a.806.806 0 001.14 0z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25 25'%3E%3Cpath d='M25 12.5C25 19.404 19.404 25 12.5 25S0 19.404 0 12.5 5.596 0 12.5 0 25 5.596 25 12.5zm-13.946 6.619l9.274-9.275a.806.806 0 000-1.14l-1.14-1.14a.806.806 0 00-1.14 0l-7.564 7.563-3.531-3.531a.807.807 0 00-1.14 0l-1.141 1.14a.806.806 0 000 1.14l5.242 5.243a.806.806 0 001.14 0z'/%3E%3C/svg%3E")}.af-validation-message__icon--error{--af-validation-icon-color:var(--digi--color--icons--danger);-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M34.4328058,0 C35.5494848,0 36.5900526,0.609146757 37.1483921,1.58958113 L47.5812454,19.9104189 C48.1395849,20.8909496 48.1395849,22.1090504 47.5812454,23.0895811 L37.1483921,41.4104189 C36.5900526,42.3908532 35.5494848,43 34.4328058,43 L13.5671942,43 C12.4505152,43 11.4099474,42.3908532 10.8516079,41.4104189 L0.41875464,23.0895811 C-0.13958488,22.1090504 -0.13958488,20.8909496 0.41875464,19.9104189 L10.8516079,1.58958113 C11.4099474,0.609146757 12.4505152,0 13.5671942,0 Z M18.6736823,12.3728178 C18.2711762,11.964729 17.6203819,11.964729 17.2178757,12.3728178 L15.2825964,14.334939 C14.8800903,14.7430278 14.8800903,15.4028485 15.2825964,15.8109373 L20.6089616,21.2111779 L15.2825964,26.6114185 C14.8800903,27.0195074 14.8800903,27.6793281 15.2825964,28.0874169 L17.2178757,30.049538 C17.6203819,30.4576269 18.2711762,30.4576269 18.6736823,30.049538 L24.0000475,24.6492974 L29.3264127,30.049538 C29.7289188,30.4576269 30.3797132,30.4576269 30.7822193,30.049538 L32.7174986,28.0874169 C33.1200048,27.6793281 33.1200048,27.0195074 32.7174986,26.6114185 L27.3911334,21.2111779 L32.7174986,15.8109373 C33.1200048,15.4028485 33.1200048,14.7430278 32.7174986,14.334939 L30.7822193,12.3728178 C30.3797132,11.964729 29.7289188,11.964729 29.3264127,12.3728178 L24.0000475,17.7730584 Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M34.4328058,0 C35.5494848,0 36.5900526,0.609146757 37.1483921,1.58958113 L47.5812454,19.9104189 C48.1395849,20.8909496 48.1395849,22.1090504 47.5812454,23.0895811 L37.1483921,41.4104189 C36.5900526,42.3908532 35.5494848,43 34.4328058,43 L13.5671942,43 C12.4505152,43 11.4099474,42.3908532 10.8516079,41.4104189 L0.41875464,23.0895811 C-0.13958488,22.1090504 -0.13958488,20.8909496 0.41875464,19.9104189 L10.8516079,1.58958113 C11.4099474,0.609146757 12.4505152,0 13.5671942,0 Z M18.6736823,12.3728178 C18.2711762,11.964729 17.6203819,11.964729 17.2178757,12.3728178 L15.2825964,14.334939 C14.8800903,14.7430278 14.8800903,15.4028485 15.2825964,15.8109373 L20.6089616,21.2111779 L15.2825964,26.6114185 C14.8800903,27.0195074 14.8800903,27.6793281 15.2825964,28.0874169 L17.2178757,30.049538 C17.6203819,30.4576269 18.2711762,30.4576269 18.6736823,30.049538 L24.0000475,24.6492974 L29.3264127,30.049538 C29.7289188,30.4576269 30.3797132,30.4576269 30.7822193,30.049538 L32.7174986,28.0874169 C33.1200048,27.6793281 33.1200048,27.0195074 32.7174986,26.6114185 L27.3911334,21.2111779 L32.7174986,15.8109373 C33.1200048,15.4028485 33.1200048,14.7430278 32.7174986,14.334939 L30.7822193,12.3728178 C30.3797132,11.964729 29.7289188,11.964729 29.3264127,12.3728178 L24.0000475,17.7730584 Z'/%3E%3C/svg%3E")}.af-validation-message__icon--warning{background:center / contain no-repeat url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Ccircle cx='9' cy='9' r='6.75' fill='%23333'/%3E%3Cpath d='m18 9c0 4.9717-4.0298 9-9 9-4.9702 0-9-4.0283-9-9 0-4.9688 4.0298-9 9-9 4.9702 0 9 4.0312 9 9zm-9 1.8145c-0.92196 0-1.6694 0.7474-1.6694 1.6694 0 0.92196 0.7474 1.6694 1.6694 1.6694s1.6694-0.7474 1.6694-1.6694c0-0.92196-0.7474-1.6694-1.6694-1.6694zm-1.3157-1.065c0.012593 0.23095 0.20355 0.41175 0.43483 0.41175h1.7618c0.23128 0 0.42224-0.1808 0.43483-0.41175l0.2692-4.9355c0.013609-0.24946-0.18501-0.45922-0.43483-0.45922h-2.3002c-0.24982 0-0.4484 0.20976-0.43479 0.45922l0.2692 4.9355z' fill='%23FFE200'/%3E%3C/svg%3E");position:relative;top:3px}.af-validation-message__text{color:var(--digi--color--text--primary);flex:1;font-family:var(--digi--global--typography--font-family--default);font-size:var(--digi--typography--tag--font-size--desktop);transform:translateY(1px)}.af-tag{display:block;max-width:100%;min-width:0}.af-tag__button{align-items:center;background:var(--digi--grayscale-0);border:var(--digi--border-width--primary) var(--digi--border-style--primary) var(--af-tag-color);border-radius:var(--digi--border-radius--secondary);color:var(--af-tag-color);cursor:pointer;display:inline-flex;font-family:var(--digi--global--typography--font-family--default);font-size:0.875rem;font-weight:var(--digi--global--typography--font-weight--semibold);gap:10px;height:auto;max-width:100%;min-height:unset;min-width:0;padding:6px 8px;text-align:left;white-space:normal}.af-tag__button:hover{background:var(--af-tag-hover-background)}.af-tag__button:focus-visible{background:var(--af-tag-hover-background);border-color:var(--af-tag-color);outline:2px solid var(--digi--grayscale-1000);outline-offset:2px;box-shadow:0 0 0 5px var(--digi--grayscale-0)}.af-tag__button:active{background:var(--af-tag-hover-background)}.af-tag__text{display:inline-block;max-width:100%;min-width:0;white-space:normal}.af-tag__text--break{overflow-wrap:anywhere;word-break:break-word}.af-tag__remove-icon{flex:0 0 auto;height:1em;min-height:0 !important;width:1em}:host{--yrkesvaljaren-width:var(--af-width, 100%);--yrkesvaljaren-min-width:var(--af-min-width, 200px);--yrkesvaljaren-max-width:var(--af-max-width, none);--yrkesvaljaren-height:var(--af-height, auto);--yrkesvaljaren-min-height:var(--af-min-height, auto);--yrkesvaljaren-max-height:var(--af-max-height, none);--selector-width:var(--yrkesvaljaren-width);--selector-min-width:var(--yrkesvaljaren-min-width);--selector-max-width:var(--yrkesvaljaren-max-width);--selector-height:var(--yrkesvaljaren-height);--selector-min-height:var(--yrkesvaljaren-min-height);--selector-max-height:var(--yrkesvaljaren-max-height);--selector-font-family:var(--global-font-family, 'Open Sans', sans-serif);--selector-font-size:var(--sok-font-size, 1rem);--selector-text-color:var(--sok-text-color, #212529);--selector-placeholder-color:var(--sok-placeholder-color, #6c757d);--selector-border-color:var(--sok-border-color, #d1d5db);--selector-focus-border-color:var(--sok-focus-border-color, #86b7fe);--selector-focus-shadow:var(--sok-focus-shadow, 0 0 0 0.25rem rgba(13, 110, 253, 0.25));--selector-background-color:var(--sok-background-color, #fff);--selector-icon-color:var(--sok-icon-color, #6c757d);--selector-clear-button-color:var(--sok-clear-button-color, #6c757d);--selector-clear-button-hover-color:var(--sok-clear-button-hover-color, #212529);--selector-suggestion-background-color:var(--sok-suggestion-background-color, #fff);--selector-suggestion-border-color:var(--sok-suggestion-border-color, #ced4da);--selector-suggestion-hover-background:var(--sok-suggestion-hover-background, #e9ecef);--selector-suggestion-active-background:var(--sok-suggestion-active-background, #dde4eb);--selector-suggestion-text-color:var(--sok-suggestion-text-color, #212529);--selector-suggestion-highlight-weight:var(--sok-suggestion-highlight-weight, bold);--selector-error-color:var(--sok-error-color, #dc3545);--selector-loading-color:var(--sok-loading-color, #6c757d);display:flex;flex-direction:column;width:var(--yrkesvaljaren-width);min-width:var(--yrkesvaljaren-min-width);max-width:var(--yrkesvaljaren-max-width);height:var(--yrkesvaljaren-height);min-height:var(--yrkesvaljaren-min-height);max-height:var(--yrkesvaljaren-max-height);position:relative}:host([af-size="small"]){--cs-size:small;--selector-container-min-height:32px;--selector-padding-y:2px;--selector-icon-size:1em;--selector-gap:4px}:host([af-size="medium"]){--selector-container-min-height:40px;--selector-padding-y:6px}:host([af-size="large"]){--selector-container-min-height:48px;--selector-padding-y:10px;--selector-icon-size:1.25em;--selector-gap:8px}*{box-sizing:border-box}.yrkesvaljaren-wrapper{display:flex;flex-direction:column;flex:1;min-height:inherit}core-selector{margin-top:0.5rem}.status-message{margin-top:0.5rem;font-size:0.9em;font-style:italic;color:var(--sok-loading-color, #6c757d)}.history-header{font-size:0.875rem;color:var(--digi--grayscale-700, #757575);border-bottom:1px solid var(--selector-suggestion-border-color, #ced4da);margin-bottom:0px}.history-header a{color:inherit;text-decoration:underline}.history-header a:hover{color:var(--digi--global--color--cta--link--base, #1616B2)}`;
|
|
873
|
+
|
|
874
|
+
{
|
|
875
|
+
taxonomyRegistry.log.setLevel(taxonomyRegistry.log.levels.SILENT);
|
|
876
|
+
}
|
|
877
|
+
const YV_LATEST_DATA_URL = 'https://d3hng02m5bee8m.cloudfront.net/yrkesvaljaren/latest.json';
|
|
878
|
+
const CONCEPT_ID_PATTERN = /^[123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ]{4}_[123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ]{3}_[123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ]{3}$/;
|
|
879
|
+
const JobSelector = class {
|
|
880
|
+
get hostElement() { return index.getElement(this); }
|
|
881
|
+
internals;
|
|
882
|
+
/**
|
|
883
|
+
* HTML-formulärets fältnamn. Följer standardattributet `name`, på samma sätt som
|
|
884
|
+
* `<input name="...">`, och används som nyckel när värdet läses via `FormData`.
|
|
885
|
+
*/
|
|
886
|
+
name = 'yrkesvaljaren';
|
|
887
|
+
/**
|
|
888
|
+
* Fullständig, explicit URL till en egenhostad datafil.
|
|
889
|
+
* Utelämna attributet för att använda senaste data automatiskt.
|
|
890
|
+
*/
|
|
891
|
+
taxonomyUrl;
|
|
892
|
+
placeholder = search_engine.PLACEHOLDER_TEXT;
|
|
893
|
+
label = 'Välj yrke';
|
|
894
|
+
/** Om satt till true kan väljaren inte användas med mus eller tangentbord. */
|
|
895
|
+
disabled = false;
|
|
896
|
+
enableMultiple = false;
|
|
897
|
+
showSearchIcon = true;
|
|
898
|
+
errorMessage;
|
|
899
|
+
labelDescription;
|
|
900
|
+
/** Om satt till `true` måste fältet ha ett värde för att vara giltigt. */
|
|
901
|
+
required = false;
|
|
902
|
+
/** Om satt till `true` tillåts fritext som inte finns i taxonomin. */
|
|
903
|
+
manualInput = false;
|
|
904
|
+
/**
|
|
905
|
+
* Aktiverar historiken.
|
|
906
|
+
* Använder Local Storage för att spara valda yrken i webbläsaren.
|
|
907
|
+
* Integritet: Data lagras bara lokalt i webbläsaren.
|
|
908
|
+
* Räckvidd: Historiken delas mellan sidor på samma domän.
|
|
909
|
+
*/
|
|
910
|
+
history = true;
|
|
911
|
+
/**
|
|
912
|
+
* Om satt till true visas populära yrken initialt när historiken är tom.
|
|
913
|
+
*/
|
|
914
|
+
popular = true;
|
|
915
|
+
// Interna konstanter och inställningar som inte exponeras för klienten
|
|
916
|
+
maxHistoryItems = 10;
|
|
917
|
+
enableSelectionAnimation = false;
|
|
918
|
+
afFocused = false;
|
|
919
|
+
populateInputOnSelect = false;
|
|
920
|
+
maxVisibleRows;
|
|
921
|
+
minLength = search_engine.MIN_INPUT_LENGTH;
|
|
922
|
+
maxResults = 10;
|
|
923
|
+
get requireSelection() {
|
|
924
|
+
return !this.manualInput;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Storleken på inputfältet.
|
|
928
|
+
* 'small' | 'medium' | 'large'
|
|
929
|
+
*/
|
|
930
|
+
size = 'medium';
|
|
931
|
+
constructor(hostRef) {
|
|
932
|
+
index.registerInstance(this, hostRef);
|
|
933
|
+
this.afJobSelectedEmitter = index.createEvent(this, "afJobSelected");
|
|
934
|
+
this.dataLoadFailedEmitter = index.createEvent(this, "afJobDataLoadFailed");
|
|
935
|
+
this.validationChangeEmitter = index.createEvent(this, "afJobValidationChange");
|
|
936
|
+
if (hostRef.$hostElement$["s-ei"]) {
|
|
937
|
+
this.internals = hostRef.$hostElement$["s-ei"];
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
this.internals = hostRef.$hostElement$.attachInternals();
|
|
941
|
+
hostRef.$hostElement$["s-ei"] = this.internals;
|
|
942
|
+
}
|
|
943
|
+
taxonomyRegistry.log.debug('[JobSelector] Constructor - enableMultiple:', this.enableMultiple);
|
|
944
|
+
}
|
|
945
|
+
state = { status: 'loading' };
|
|
946
|
+
/**
|
|
947
|
+
* Spårar vilken typ av fel som är aktivt,
|
|
948
|
+
* för att kunna visa rätt meddelande.
|
|
949
|
+
*/
|
|
950
|
+
activeErrorType = null;
|
|
951
|
+
animatingItemId = null;
|
|
952
|
+
animationTimer;
|
|
953
|
+
cloneSelection(selection) {
|
|
954
|
+
return selection.map((item) => item.id === null
|
|
955
|
+
? { ...item }
|
|
956
|
+
: {
|
|
957
|
+
...item,
|
|
958
|
+
related: item.related ? { ...item.related } : undefined,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
rebuildValidJobIds() {
|
|
962
|
+
const taxonomyData = this.controller?.getTaxonomyData?.() ?? this.taxonomyData ?? [];
|
|
963
|
+
this.validJobIds = new Set(taxonomyData.map((item) => item.id).filter(Boolean));
|
|
964
|
+
}
|
|
965
|
+
validateSelectionCandidate(candidate) {
|
|
966
|
+
const values = typeof candidate === 'string'
|
|
967
|
+
? [candidate]
|
|
968
|
+
: Array.isArray(candidate)
|
|
969
|
+
? candidate
|
|
970
|
+
: null;
|
|
971
|
+
if (!values) {
|
|
972
|
+
return { ok: false, reason: 'invalid-input' };
|
|
973
|
+
}
|
|
974
|
+
const hasStrings = values.some((item) => typeof item === 'string');
|
|
975
|
+
const hasObjects = values.some((item) => typeof item === 'object' && item !== null);
|
|
976
|
+
if ((hasStrings && hasObjects) || values.some((item) => typeof item !== 'string' && (typeof item !== 'object' || item === null))) {
|
|
977
|
+
return { ok: false, reason: 'invalid-input' };
|
|
978
|
+
}
|
|
979
|
+
const selection = [];
|
|
980
|
+
const taxonomyIds = [];
|
|
981
|
+
const manualLabels = [];
|
|
982
|
+
for (const item of values) {
|
|
983
|
+
if (typeof item === 'string') {
|
|
984
|
+
const value = item.trim();
|
|
985
|
+
if (!value) {
|
|
986
|
+
return { ok: false, reason: 'invalid-input' };
|
|
987
|
+
}
|
|
988
|
+
if (CONCEPT_ID_PATTERN.test(value)) {
|
|
989
|
+
taxonomyIds.push(value);
|
|
990
|
+
selection.push({ id: value });
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
if (!this.manualInput) {
|
|
994
|
+
return { ok: false, reason: 'manual-input-disabled' };
|
|
995
|
+
}
|
|
996
|
+
manualLabels.push(value);
|
|
997
|
+
selection.push({ type: 'manual-input', id: null, preferred_label: value });
|
|
998
|
+
}
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
if (!item || typeof item !== 'object') {
|
|
1002
|
+
return { ok: false, reason: 'invalid-input' };
|
|
1003
|
+
}
|
|
1004
|
+
const candidateItem = item;
|
|
1005
|
+
if (typeof candidateItem.id === 'string') {
|
|
1006
|
+
const id = candidateItem.id.trim();
|
|
1007
|
+
if (!id
|
|
1008
|
+
|| (candidateItem.type !== 'job-title'
|
|
1009
|
+
&& candidateItem.type !== 'occupation-name')
|
|
1010
|
+
|| typeof candidateItem.preferred_label !== 'string'
|
|
1011
|
+
|| !candidateItem.preferred_label.trim()) {
|
|
1012
|
+
return { ok: false, reason: 'invalid-input' };
|
|
1013
|
+
}
|
|
1014
|
+
if (candidateItem.related !== undefined) {
|
|
1015
|
+
const related = candidateItem.related;
|
|
1016
|
+
if (!related
|
|
1017
|
+
|| related.type !== 'occupation-name'
|
|
1018
|
+
|| typeof related.id !== 'string'
|
|
1019
|
+
|| !related.id.trim()
|
|
1020
|
+
|| typeof related.preferred_label !== 'string'
|
|
1021
|
+
|| !related.preferred_label.trim()) {
|
|
1022
|
+
return { ok: false, reason: 'invalid-input' };
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
taxonomyIds.push(id);
|
|
1026
|
+
selection.push({ id });
|
|
1027
|
+
}
|
|
1028
|
+
else if (candidateItem.id === null && candidateItem.type === 'manual-input') {
|
|
1029
|
+
if (typeof candidateItem.preferred_label !== 'string' || !candidateItem.preferred_label.trim()) {
|
|
1030
|
+
return { ok: false, reason: 'invalid-input' };
|
|
1031
|
+
}
|
|
1032
|
+
if (!this.manualInput) {
|
|
1033
|
+
return { ok: false, reason: 'manual-input-disabled' };
|
|
1034
|
+
}
|
|
1035
|
+
const label = candidateItem.preferred_label.trim();
|
|
1036
|
+
manualLabels.push(label);
|
|
1037
|
+
selection.push({ type: 'manual-input', id: null, preferred_label: label });
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
return { ok: false, reason: 'invalid-input' };
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (!this.enableMultiple && selection.length > 1) {
|
|
1044
|
+
return { ok: false, reason: 'multiple-disabled' };
|
|
1045
|
+
}
|
|
1046
|
+
if (new Set(taxonomyIds).size !== taxonomyIds.length) {
|
|
1047
|
+
return { ok: false, reason: 'duplicate-taxonomy-id' };
|
|
1048
|
+
}
|
|
1049
|
+
if (new Set(manualLabels).size !== manualLabels.length) {
|
|
1050
|
+
return { ok: false, reason: 'duplicate-manual-label' };
|
|
1051
|
+
}
|
|
1052
|
+
const invalidIds = taxonomyIds.filter((id) => !this.validJobIds.has(id));
|
|
1053
|
+
if (invalidIds.length > 0) {
|
|
1054
|
+
return { ok: false, reason: 'unknown-id', invalidIds };
|
|
1055
|
+
}
|
|
1056
|
+
return { ok: true, selection };
|
|
1057
|
+
}
|
|
1058
|
+
describeSelectionRejection(rejection) {
|
|
1059
|
+
switch (rejection.reason) {
|
|
1060
|
+
case 'invalid-input':
|
|
1061
|
+
return 'setSelection accepterar en sträng, en homogen strängarray eller en array med fullständiga JobSelectionItem-objekt.';
|
|
1062
|
+
case 'data-unavailable':
|
|
1063
|
+
return 'setSelection kan inte användas eftersom yrkesdata inte kunde laddas.';
|
|
1064
|
+
case 'duplicate-taxonomy-id':
|
|
1065
|
+
return 'selection får inte innehålla dubbla taxonomi-id:n.';
|
|
1066
|
+
case 'duplicate-manual-label':
|
|
1067
|
+
return 'selection får inte innehålla dubbla manuella labels.';
|
|
1068
|
+
case 'multiple-disabled':
|
|
1069
|
+
return 'flera värden kräver af-enable-multiple="true".';
|
|
1070
|
+
case 'manual-input-disabled':
|
|
1071
|
+
return 'manual-val kräver af-manual-input="true".';
|
|
1072
|
+
case 'unknown-id':
|
|
1073
|
+
return `okända Concept ID:n: ${rejection.invalidIds?.join(', ')}`;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
errorCodeForRejection(reason) {
|
|
1077
|
+
switch (reason) {
|
|
1078
|
+
case 'unknown-id':
|
|
1079
|
+
return 'unknown-id';
|
|
1080
|
+
case 'manual-input-disabled':
|
|
1081
|
+
return 'manual-input-disabled';
|
|
1082
|
+
case 'multiple-disabled':
|
|
1083
|
+
return 'multiple-disabled';
|
|
1084
|
+
default:
|
|
1085
|
+
return 'invalid-input';
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
rejectSelection(candidate, rejection) {
|
|
1089
|
+
const message = this.describeSelectionRejection(rejection);
|
|
1090
|
+
console.error(`[Yrkesväljaren] Ogiltig selection: ${message} Värdet accepterades inte; senaste giltiga val behålls.`, { selection: candidate, previousSelection: this.lastAcceptedSelection });
|
|
1091
|
+
this.activeErrorType = 'invalidInput';
|
|
1092
|
+
this.validationChangeEmitter.emit({ isValid: false, errorType: 'invalidInput' });
|
|
1093
|
+
this.updateValidity(false);
|
|
1094
|
+
return new types.InvalidSelectionError(message, this.errorCodeForRejection(rejection.reason), rejection.invalidIds);
|
|
1095
|
+
}
|
|
1096
|
+
syncAcceptedSelection(selection) {
|
|
1097
|
+
const nextSelection = this.cloneSelection(selection);
|
|
1098
|
+
this.lastAcceptedSelection = nextSelection;
|
|
1099
|
+
if (this.activeErrorType === 'invalidInput') {
|
|
1100
|
+
const hasAcceptedSelection = nextSelection.length > 0;
|
|
1101
|
+
const isValid = hasAcceptedSelection || !this.required;
|
|
1102
|
+
const errorType = isValid ? null : 'required';
|
|
1103
|
+
this.activeErrorType = errorType;
|
|
1104
|
+
this.validationChangeEmitter.emit({ isValid, errorType });
|
|
1105
|
+
this.updateValidity(isValid);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
applySelection(candidate) {
|
|
1109
|
+
if (!this.controller) {
|
|
1110
|
+
throw this.rejectSelection(candidate, { ok: false, reason: 'data-unavailable' });
|
|
1111
|
+
}
|
|
1112
|
+
const validation = this.validateSelectionCandidate(candidate);
|
|
1113
|
+
if (!validation.ok) {
|
|
1114
|
+
throw this.rejectSelection(candidate, validation);
|
|
1115
|
+
}
|
|
1116
|
+
const result = this.controller.setSelection(validation.selection);
|
|
1117
|
+
if (!result.ok) {
|
|
1118
|
+
throw this.rejectSelection(candidate, { ok: false, reason: 'unknown-id', invalidIds: result.invalidIds });
|
|
1119
|
+
}
|
|
1120
|
+
this.syncAcceptedSelection(result.selection);
|
|
1121
|
+
return this.cloneSelection(result.selection);
|
|
1122
|
+
}
|
|
1123
|
+
watchRequired(newValue) {
|
|
1124
|
+
this.controller.updateConfig({ required: newValue });
|
|
1125
|
+
}
|
|
1126
|
+
watchManualInput(newValue) {
|
|
1127
|
+
this.controller.updateConfig({ requireSelection: !newValue });
|
|
1128
|
+
}
|
|
1129
|
+
watchHistory(newValue) {
|
|
1130
|
+
this.controller.updateConfig({ history: newValue });
|
|
1131
|
+
}
|
|
1132
|
+
watchPopular(newValue) {
|
|
1133
|
+
this.controller.updateConfig({ popular: newValue });
|
|
1134
|
+
}
|
|
1135
|
+
watchEnableMultiple(newValue) {
|
|
1136
|
+
this.controller.updateConfig({ enableMultiple: newValue });
|
|
1137
|
+
}
|
|
1138
|
+
watchDisabled(newValue) {
|
|
1139
|
+
if (newValue)
|
|
1140
|
+
this.afFocused = false;
|
|
1141
|
+
}
|
|
1142
|
+
watchTaxonomyData(newValue) {
|
|
1143
|
+
if (this.controller && newValue && newValue.length > 0) {
|
|
1144
|
+
this.controller.initialize(newValue).then(() => {
|
|
1145
|
+
this.rebuildValidJobIds();
|
|
1146
|
+
this.restoreAcceptedSelectionAfterDataUpdate();
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Yrkesdata (taxonomi). Kan injiceras av integratören för deterministisk laddning (t.ex. i testmiljöer).
|
|
1152
|
+
* Om den lämnas tom populeras den internt via det globala registret eller auto-fetch för att exponera aktuell data.
|
|
1153
|
+
*/
|
|
1154
|
+
taxonomyData = [];
|
|
1155
|
+
afJobSelectedEmitter;
|
|
1156
|
+
syncFormValue() {
|
|
1157
|
+
if (!this.state)
|
|
1158
|
+
return;
|
|
1159
|
+
if (this.enableMultiple) {
|
|
1160
|
+
const selected = (this.state.status === 'multiSelect' || this.state.status === 'multiSelectShowingHistory')
|
|
1161
|
+
? this.state.selectedItems
|
|
1162
|
+
: [];
|
|
1163
|
+
const formData = new FormData();
|
|
1164
|
+
selected.forEach(item => {
|
|
1165
|
+
formData.append(this.name, item.id || item.rawLabel);
|
|
1166
|
+
});
|
|
1167
|
+
this.internals.setFormValue(formData);
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
const selected = this.state.status === 'itemSelected' ? this.state.selectedItem : null;
|
|
1171
|
+
this.internals.setFormValue(selected ? selected.id || selected.rawLabel : null);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
handleFormValidationSync(event) {
|
|
1175
|
+
this.updateValidity(event.detail.isValid);
|
|
1176
|
+
}
|
|
1177
|
+
dataLoadFailedEmitter;
|
|
1178
|
+
validationChangeEmitter;
|
|
1179
|
+
static nextId = 0;
|
|
1180
|
+
componentId = `job-selector-${JobSelector.nextId++}`;
|
|
1181
|
+
observer;
|
|
1182
|
+
controller;
|
|
1183
|
+
validJobIds = new Set();
|
|
1184
|
+
lastAcceptedSelection = [];
|
|
1185
|
+
restoreAcceptedSelectionAfterDataUpdate() {
|
|
1186
|
+
if (this.lastAcceptedSelection.length === 0) {
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const result = this.controller.setSelection(this.lastAcceptedSelection);
|
|
1190
|
+
if (result.ok) {
|
|
1191
|
+
this.syncAcceptedSelection(result.selection);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
this.rejectSelection(this.lastAcceptedSelection, {
|
|
1195
|
+
ok: false,
|
|
1196
|
+
reason: 'unknown-id',
|
|
1197
|
+
invalidIds: result.invalidIds,
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
// --- LIVSCYKEL-METODER ---
|
|
1201
|
+
async componentWillLoad() {
|
|
1202
|
+
taxonomyRegistry.log.setLevel(taxonomyRegistry.log.levels.WARN);
|
|
1203
|
+
// Använd registrerad data om den finns. Annars används en explicit URL
|
|
1204
|
+
// eller senaste publicerade data.
|
|
1205
|
+
const registeredData = taxonomyRegistry.getRegisteredTaxonomy();
|
|
1206
|
+
const hasData = (this.taxonomyData && this.taxonomyData.length > 0) || (registeredData && registeredData.length > 0);
|
|
1207
|
+
if (!hasData) {
|
|
1208
|
+
this.state = { status: 'loading' };
|
|
1209
|
+
try {
|
|
1210
|
+
const dataUrl = this.taxonomyUrl ?? YV_LATEST_DATA_URL;
|
|
1211
|
+
taxonomyRegistry.log.debug(`[JobSelector] Hämtar yrkesdata från: ${dataUrl}`);
|
|
1212
|
+
const response = await fetch(dataUrl);
|
|
1213
|
+
if (!response.ok) {
|
|
1214
|
+
throw new Error(`Kunde inte ladda yrkesdata från ${dataUrl}: ${response.status} ${response.statusText}`);
|
|
1215
|
+
}
|
|
1216
|
+
const data = await response.json();
|
|
1217
|
+
if (!taxonomyRegistry.isTaxonomyRegistration(data)) {
|
|
1218
|
+
throw new Error('Yrkesdatan har ett ogiltigt format.');
|
|
1219
|
+
}
|
|
1220
|
+
taxonomyRegistry.registerTaxonomy(data);
|
|
1221
|
+
taxonomyRegistry.log.debug('[JobSelector] Data-laddning lyckades.');
|
|
1222
|
+
}
|
|
1223
|
+
catch (error) {
|
|
1224
|
+
taxonomyRegistry.log.error('[JobSelector] Fel vid data-laddning:', error);
|
|
1225
|
+
this.state = { status: 'error', message: 'Kunde inte ladda yrkesdata.' };
|
|
1226
|
+
this.dataLoadFailedEmitter.emit({ error: error instanceof Error ? error : new Error(String(error)) });
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
this.controller = new JobSelectorController({
|
|
1231
|
+
onStateChange: (state) => {
|
|
1232
|
+
this.state = { ...state };
|
|
1233
|
+
this.syncFormValue();
|
|
1234
|
+
},
|
|
1235
|
+
onEmitSelection: (selection) => {
|
|
1236
|
+
const nextSelection = selection
|
|
1237
|
+
? (Array.isArray(selection) ? selection : [selection])
|
|
1238
|
+
: [];
|
|
1239
|
+
this.syncAcceptedSelection(nextSelection);
|
|
1240
|
+
this.afJobSelectedEmitter.emit(nextSelection);
|
|
1241
|
+
},
|
|
1242
|
+
onEmitValidation: (payload) => {
|
|
1243
|
+
this.activeErrorType = payload.errorType;
|
|
1244
|
+
this.validationChangeEmitter.emit(payload);
|
|
1245
|
+
},
|
|
1246
|
+
onEmitDataLoadFailed: (error) => { this.dataLoadFailedEmitter.emit({ error }); },
|
|
1247
|
+
onEmitStatusChange: () => { },
|
|
1248
|
+
}, {
|
|
1249
|
+
enableMultiple: this.enableMultiple,
|
|
1250
|
+
required: this.required,
|
|
1251
|
+
requireSelection: this.requireSelection,
|
|
1252
|
+
history: this.history,
|
|
1253
|
+
popular: this.popular,
|
|
1254
|
+
maxHistoryItems: this.maxHistoryItems,
|
|
1255
|
+
minLength: this.minLength,
|
|
1256
|
+
maxResults: this.maxResults,
|
|
1257
|
+
});
|
|
1258
|
+
// Initialisera controller (den läser in data från registret eller använder injicerad data)
|
|
1259
|
+
await this.controller.initialize(this.taxonomyData);
|
|
1260
|
+
this.taxonomyData = this.controller.getTaxonomyData();
|
|
1261
|
+
this.rebuildValidJobIds();
|
|
1262
|
+
// Sätt initial valideringsstatus för FACE
|
|
1263
|
+
if (this.activeErrorType === 'invalidInput') {
|
|
1264
|
+
this.updateValidity(false);
|
|
1265
|
+
}
|
|
1266
|
+
else if (this.required && this.lastAcceptedSelection.length === 0) {
|
|
1267
|
+
this.internals.setValidity({ customError: true }, this.errorMessage || 'Välj ett yrke från listan.');
|
|
1268
|
+
}
|
|
1269
|
+
else {
|
|
1270
|
+
this.internals.setValidity({});
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
componentDidLoad() {
|
|
1274
|
+
this.handleHashFocus();
|
|
1275
|
+
}
|
|
1276
|
+
disconnectedCallback() {
|
|
1277
|
+
if (this.observer) {
|
|
1278
|
+
this.observer.disconnect();
|
|
1279
|
+
}
|
|
1280
|
+
if (this.controller) {
|
|
1281
|
+
this.controller.destroy();
|
|
1282
|
+
}
|
|
1283
|
+
clearTimeout(this.animationTimer);
|
|
1284
|
+
this.animationTimer = undefined;
|
|
1285
|
+
}
|
|
1286
|
+
handleHashFocus() {
|
|
1287
|
+
const hash = window.location.hash;
|
|
1288
|
+
if (!hash)
|
|
1289
|
+
return;
|
|
1290
|
+
const targetId = hash.substring(1);
|
|
1291
|
+
const hostId = this.hostElement.getAttribute('id');
|
|
1292
|
+
if (hostId && targetId === hostId) {
|
|
1293
|
+
this.setFocus();
|
|
1294
|
+
this.hostElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1295
|
+
history.replaceState(null, '', window.location.pathname + window.location.search);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
// --- ANVÄNDARINTERAKTIONER OCH LOGIK (Delegerat till Controller via CoreSelector events) ---
|
|
1299
|
+
handleInput(event) {
|
|
1300
|
+
this.afFocused = true;
|
|
1301
|
+
this.controller.handleInput(event.detail);
|
|
1302
|
+
}
|
|
1303
|
+
handleCsSelect(event) {
|
|
1304
|
+
this.afFocused = true;
|
|
1305
|
+
const genericItem = event.detail;
|
|
1306
|
+
if (this.enableSelectionAnimation) {
|
|
1307
|
+
this.animatingItemId = genericItem.id;
|
|
1308
|
+
clearTimeout(this.animationTimer);
|
|
1309
|
+
this.animationTimer = setTimeout(() => {
|
|
1310
|
+
this.animatingItemId = null;
|
|
1311
|
+
this.animationTimer = undefined;
|
|
1312
|
+
}, 600);
|
|
1313
|
+
}
|
|
1314
|
+
// Hämta originalobjektet från metadata om det finns, annars bygg ett baserat på genericItem
|
|
1315
|
+
const originalItem = genericItem.metadata?.original;
|
|
1316
|
+
if (originalItem && 'displayLabel' in originalItem) {
|
|
1317
|
+
this.controller.selectItem(originalItem);
|
|
1318
|
+
}
|
|
1319
|
+
else {
|
|
1320
|
+
// Fallback för manuell inmatning eller om metadata saknas
|
|
1321
|
+
if (genericItem.metadata?.type === 'ManualInput') {
|
|
1322
|
+
this.controller.handleFreeTextSelection(genericItem.label);
|
|
1323
|
+
}
|
|
1324
|
+
else {
|
|
1325
|
+
// Detta borde inte hända med data från suggestions, men för säkerhets skull
|
|
1326
|
+
const searchResult = {
|
|
1327
|
+
id: genericItem.id,
|
|
1328
|
+
type: 'unknown',
|
|
1329
|
+
preferred_label: genericItem.label,
|
|
1330
|
+
rawLabel: genericItem.label,
|
|
1331
|
+
displayLabel: genericItem.label,
|
|
1332
|
+
highlightedLabel: genericItem.highlightedLabel || genericItem.label,
|
|
1333
|
+
score: 1.0
|
|
1334
|
+
};
|
|
1335
|
+
this.controller.selectItem(searchResult);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
handleCsClear() {
|
|
1340
|
+
this.controller.resetSearch();
|
|
1341
|
+
}
|
|
1342
|
+
handleCsClearHistory() {
|
|
1343
|
+
this.controller.clearHistory();
|
|
1344
|
+
}
|
|
1345
|
+
handleCsRequestHistory() {
|
|
1346
|
+
this.controller.showHistory();
|
|
1347
|
+
}
|
|
1348
|
+
handleCsLoadMore() {
|
|
1349
|
+
this.controller.loadMore();
|
|
1350
|
+
}
|
|
1351
|
+
handleCsRemoveItem(event) {
|
|
1352
|
+
const genericItem = event.detail;
|
|
1353
|
+
const originalItem = genericItem.metadata?.original;
|
|
1354
|
+
if (originalItem && 'displayLabel' in originalItem) {
|
|
1355
|
+
this.controller.removeItem(originalItem);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
handleCsFocus() {
|
|
1359
|
+
this.afFocused = true;
|
|
1360
|
+
this.controller.handleFocus();
|
|
1361
|
+
}
|
|
1362
|
+
handleCsBlur() {
|
|
1363
|
+
this.afFocused = false;
|
|
1364
|
+
this.controller.handleBlur();
|
|
1365
|
+
this.validate(); // Ensure events are emitted on blur
|
|
1366
|
+
}
|
|
1367
|
+
handleCsEditSelection() {
|
|
1368
|
+
this.afFocused = true;
|
|
1369
|
+
this.controller.editSelection();
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Uppdaterar taxonomidatan genom att läsa om från registret.
|
|
1373
|
+
* Används efter att registerTaxonomy() har anropats med ny data.
|
|
1374
|
+
*/
|
|
1375
|
+
async updateData() {
|
|
1376
|
+
taxonomyRegistry.log.debug('[JobSelector] updateData called');
|
|
1377
|
+
await this.controller.initialize();
|
|
1378
|
+
this.taxonomyData = this.controller.getTaxonomyData();
|
|
1379
|
+
this.rebuildValidJobIds();
|
|
1380
|
+
this.restoreAcceptedSelectionAfterDataUpdate();
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Sätter ett programatiskt val och returnerar YV:s normaliserade, rika selection.
|
|
1384
|
+
* Strängar som matchar Concept ID-formatet tolkas som taxonomi-id:n; övriga som fritext.
|
|
1385
|
+
*/
|
|
1386
|
+
async setSelection(input) {
|
|
1387
|
+
return this.applySelection(input);
|
|
1388
|
+
}
|
|
1389
|
+
async setFocus() {
|
|
1390
|
+
const coreSelector = this.hostElement.shadowRoot?.querySelector('core-selector');
|
|
1391
|
+
if (coreSelector && typeof coreSelector.setFocus === 'function') {
|
|
1392
|
+
await coreSelector.setFocus();
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Validerar komponenten manuellt.
|
|
1397
|
+
* @param forceTouch - Om true, tvingas valideringsfel att visas även om användaren inte interagerat med fältet.
|
|
1398
|
+
*/
|
|
1399
|
+
async validate(forceTouch = false) {
|
|
1400
|
+
const isValid = await this.controller.runValidation(forceTouch);
|
|
1401
|
+
this.updateValidity(isValid);
|
|
1402
|
+
return isValid;
|
|
1403
|
+
}
|
|
1404
|
+
async checkValidity() {
|
|
1405
|
+
return this.internals.checkValidity();
|
|
1406
|
+
}
|
|
1407
|
+
async reportValidity() {
|
|
1408
|
+
return this.internals.reportValidity();
|
|
1409
|
+
}
|
|
1410
|
+
async getValidity() {
|
|
1411
|
+
return this.internals.validity;
|
|
1412
|
+
}
|
|
1413
|
+
async getValidationMessage() {
|
|
1414
|
+
return this.internals.validationMessage;
|
|
1415
|
+
}
|
|
1416
|
+
updateValidity(isValid) {
|
|
1417
|
+
if (!isValid) {
|
|
1418
|
+
const message = this.activeErrorType === 'invalidInput'
|
|
1419
|
+
? this.errorMessage || 'Komponenten har fått ett ogiltigt värde.'
|
|
1420
|
+
: this.errorMessage || 'Välj ett yrke från listan.';
|
|
1421
|
+
this.internals.setValidity({ customError: true }, message);
|
|
1422
|
+
}
|
|
1423
|
+
else {
|
|
1424
|
+
this.internals.setValidity({});
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// --- MAPPING HELPERS ---
|
|
1428
|
+
_mapSearchResultToGenericItem(item) {
|
|
1429
|
+
const isSelectedResult = this.enableMultiple
|
|
1430
|
+
? (this.state.status === 'multiSelect' || this.state.status === 'multiSelectShowingHistory') &&
|
|
1431
|
+
this.state.selectedItems.some(si => {
|
|
1432
|
+
if (si.id && item.id)
|
|
1433
|
+
return si.id === item.id;
|
|
1434
|
+
return si.rawLabel === item.rawLabel;
|
|
1435
|
+
})
|
|
1436
|
+
: this.state.status === 'itemSelected' && this.state.selectedItem?.id === item.id;
|
|
1437
|
+
return {
|
|
1438
|
+
id: item.id,
|
|
1439
|
+
label: item.displayLabel, // Use the formatted label
|
|
1440
|
+
isSelected: this.animatingItemId === item.id || isSelectedResult,
|
|
1441
|
+
highlightedLabel: item.highlightedLabel,
|
|
1442
|
+
displaySegments: item.displaySegments, // Pass structured segments
|
|
1443
|
+
group: item.type === 'occupation-name' ? 'Yrkesgrupper' : 'Yrkesbenämningar',
|
|
1444
|
+
metadata: {
|
|
1445
|
+
original: item // Store the full object for retrieval
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
_mapStateToSelectorState(componentState) {
|
|
1450
|
+
let coreStatus = 'loading';
|
|
1451
|
+
let inputValue = '';
|
|
1452
|
+
let suggestions = [];
|
|
1453
|
+
let selectedItem;
|
|
1454
|
+
let selectedItems = [];
|
|
1455
|
+
let validation = 'idle';
|
|
1456
|
+
let message = this.errorMessage || '';
|
|
1457
|
+
let validationContext = undefined;
|
|
1458
|
+
let listHeader;
|
|
1459
|
+
let showHistoryHeader;
|
|
1460
|
+
// Map Validation
|
|
1461
|
+
if ('validation' in componentState && componentState.validation) {
|
|
1462
|
+
validation = componentState.validation;
|
|
1463
|
+
if (componentState.validation === 'error') {
|
|
1464
|
+
if (this.activeErrorType === 'required') {
|
|
1465
|
+
message = this.errorMessage || 'Välj ett yrke från listan.';
|
|
1466
|
+
}
|
|
1467
|
+
else if (this.activeErrorType === 'invalidInput') {
|
|
1468
|
+
message = this.errorMessage || 'Välj ett yrke från listan.';
|
|
1469
|
+
}
|
|
1470
|
+
else if (this.errorMessage) {
|
|
1471
|
+
message = this.errorMessage;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
else if (componentState.validation === 'success') {
|
|
1475
|
+
message = 'Tack!';
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if ('message' in componentState && componentState.message) {
|
|
1479
|
+
message = componentState.message;
|
|
1480
|
+
}
|
|
1481
|
+
if ('validationContext' in componentState) {
|
|
1482
|
+
validationContext = componentState.validationContext;
|
|
1483
|
+
}
|
|
1484
|
+
// Map Status & Data
|
|
1485
|
+
switch (componentState.status) {
|
|
1486
|
+
case 'loading':
|
|
1487
|
+
coreStatus = 'loading';
|
|
1488
|
+
break;
|
|
1489
|
+
case 'error':
|
|
1490
|
+
coreStatus = 'error';
|
|
1491
|
+
break;
|
|
1492
|
+
case 'searching':
|
|
1493
|
+
coreStatus = 'searching';
|
|
1494
|
+
inputValue = componentState.inputValue;
|
|
1495
|
+
suggestions = componentState.suggestions.map(s => this._mapSearchResultToGenericItem(s));
|
|
1496
|
+
break;
|
|
1497
|
+
case 'showingHistory':
|
|
1498
|
+
coreStatus = 'searching';
|
|
1499
|
+
inputValue = componentState.inputValue || '';
|
|
1500
|
+
suggestions = componentState.historyItems.map(s => this._mapSearchResultToGenericItem(s));
|
|
1501
|
+
break;
|
|
1502
|
+
case 'itemSelected':
|
|
1503
|
+
coreStatus = 'itemSelected';
|
|
1504
|
+
selectedItem = this._mapSearchResultToGenericItem(componentState.selectedItem);
|
|
1505
|
+
selectedItem.highlightedLabel = search_engine.styleParentheticalText(selectedItem.label);
|
|
1506
|
+
inputValue = componentState.inputValue || '';
|
|
1507
|
+
break;
|
|
1508
|
+
case 'multiSelect':
|
|
1509
|
+
coreStatus = 'multiSelect';
|
|
1510
|
+
inputValue = componentState.inputValue;
|
|
1511
|
+
suggestions = componentState.suggestions.map(s => this._mapSearchResultToGenericItem(s));
|
|
1512
|
+
selectedItems = componentState.selectedItems.map(s => this._mapSearchResultToGenericItem(s));
|
|
1513
|
+
break;
|
|
1514
|
+
case 'multiSelectShowingHistory':
|
|
1515
|
+
coreStatus = 'multiSelect';
|
|
1516
|
+
inputValue = componentState.inputValue || '';
|
|
1517
|
+
suggestions = componentState.historyItems.map(s => this._mapSearchResultToGenericItem(s));
|
|
1518
|
+
selectedItems = componentState.selectedItems.map(s => this._mapSearchResultToGenericItem(s));
|
|
1519
|
+
break;
|
|
1520
|
+
}
|
|
1521
|
+
const isHistoryView = componentState.status === 'showingHistory' || componentState.status === 'multiSelectShowingHistory';
|
|
1522
|
+
if (coreStatus === 'multiSelect' || coreStatus === 'itemSelected' || coreStatus === 'searching') {
|
|
1523
|
+
// 1. Hide already selected items from the dropdown (YV specific requirement)
|
|
1524
|
+
if (selectedItems.length > 0) {
|
|
1525
|
+
suggestions = suggestions.filter(s => {
|
|
1526
|
+
return !selectedItems.some(si => (si.id && s.id) ? si.id === s.id : si.label === s.label);
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
// 1. Set dynamic list header
|
|
1530
|
+
if (isHistoryView && suggestions.length > 0) {
|
|
1531
|
+
if (componentState.isPopularView) {
|
|
1532
|
+
listHeader = 'Populära sökningar';
|
|
1533
|
+
showHistoryHeader = false; // Hide Rensa link
|
|
1534
|
+
}
|
|
1535
|
+
else {
|
|
1536
|
+
// Rule: Historik should show "Din privata historik - Rensa".
|
|
1537
|
+
// We let CoreSelector's default HistoryHeader handle this
|
|
1538
|
+
showHistoryHeader = true;
|
|
1539
|
+
listHeader = undefined;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
else {
|
|
1543
|
+
// Reset when searching or when no suggestions
|
|
1544
|
+
listHeader = undefined;
|
|
1545
|
+
showHistoryHeader = false;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
else {
|
|
1549
|
+
// Not in a state that should show a list header
|
|
1550
|
+
listHeader = undefined;
|
|
1551
|
+
showHistoryHeader = false;
|
|
1552
|
+
}
|
|
1553
|
+
return {
|
|
1554
|
+
status: coreStatus,
|
|
1555
|
+
inputValue,
|
|
1556
|
+
suggestions,
|
|
1557
|
+
selectedItem,
|
|
1558
|
+
selectedItems,
|
|
1559
|
+
validation,
|
|
1560
|
+
validationContext,
|
|
1561
|
+
message,
|
|
1562
|
+
listHeader,
|
|
1563
|
+
showHistoryHeader,
|
|
1564
|
+
isHistoryView
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
render() {
|
|
1568
|
+
const selectorState = this._mapStateToSelectorState(this.state);
|
|
1569
|
+
// Explicitly construct config to match SelectorConfig interface
|
|
1570
|
+
const config = {
|
|
1571
|
+
disabled: this.disabled,
|
|
1572
|
+
placeholder: this.placeholder,
|
|
1573
|
+
minLength: this.minLength,
|
|
1574
|
+
maxResults: this.maxResults,
|
|
1575
|
+
enableMultiple: this.enableMultiple,
|
|
1576
|
+
showSearchIcon: this.showSearchIcon,
|
|
1577
|
+
requireSelection: this.requireSelection,
|
|
1578
|
+
size: this.size,
|
|
1579
|
+
enableSelectionAnimation: this.enableSelectionAnimation,
|
|
1580
|
+
populateInputOnSelect: this.populateInputOnSelect,
|
|
1581
|
+
maxVisibleRows: this.maxVisibleRows,
|
|
1582
|
+
manualInputBehavior: 'select',
|
|
1583
|
+
showHistoryHeader: this.history && !selectorState.listHeader, // Only show default if not override
|
|
1584
|
+
listHeader: selectorState.listHeader,
|
|
1585
|
+
enableInfiniteScroll: true
|
|
1586
|
+
};
|
|
1587
|
+
// Pass external error message if present and we are not in loading/error state
|
|
1588
|
+
// (This logic is now moved to _mapStateToSelectorState to respect validation state)
|
|
1589
|
+
return (index.h("div", { key: '11b208182109be21dcf759010cb87f1bbd10b618', class: "job-selector-wrapper" }, this.state.status === 'error' ? (index.h("div", { class: "error-message" }, index.h(afValidationMessage.AfValidationMessage, { variant: "error", role: "alert" }, this.state.message || 'Ett fel uppstod.'))) : (index.h(index.Fragment, null, index.h("core-selector", { id: this.componentId, componentId: this.componentId, state: selectorState, config: config, label: this.label, labelDescription: this.labelDescription, size: this.size, afFocused: this.afFocused })))));
|
|
1590
|
+
}
|
|
1591
|
+
static get delegatesFocus() { return true; }
|
|
1592
|
+
static get formAssociated() { return true; }
|
|
1593
|
+
static get watchers() { return {
|
|
1594
|
+
"required": [{
|
|
1595
|
+
"watchRequired": 0
|
|
1596
|
+
}],
|
|
1597
|
+
"manualInput": [{
|
|
1598
|
+
"watchManualInput": 0
|
|
1599
|
+
}],
|
|
1600
|
+
"history": [{
|
|
1601
|
+
"watchHistory": 0
|
|
1602
|
+
}],
|
|
1603
|
+
"popular": [{
|
|
1604
|
+
"watchPopular": 0
|
|
1605
|
+
}],
|
|
1606
|
+
"enableMultiple": [{
|
|
1607
|
+
"watchEnableMultiple": 0
|
|
1608
|
+
}],
|
|
1609
|
+
"disabled": [{
|
|
1610
|
+
"watchDisabled": 0
|
|
1611
|
+
}],
|
|
1612
|
+
"taxonomyData": [{
|
|
1613
|
+
"watchTaxonomyData": 0
|
|
1614
|
+
}]
|
|
1615
|
+
}; }
|
|
1616
|
+
};
|
|
1617
|
+
JobSelector.style = jobSelectorCss();
|
|
1618
|
+
|
|
1619
|
+
exports.digi_service_job_selector = JobSelector;
|