fds-vue-core 2.1.4 → 2.1.6
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/components.d.ts +8 -0
- package/configs/tsconfig.base.json +2 -1
- package/dist/fds-vue-core.cjs.js +35 -15
- package/dist/fds-vue-core.cjs.js.map +1 -1
- package/dist/fds-vue-core.es.js +35 -15
- package/dist/fds-vue-core.es.js.map +1 -1
- package/dist/global-components.d.ts +35 -33
- package/package.json +23 -21
- package/src/.DS_Store +0 -0
- package/src/App.vue +133 -0
- package/src/apply.css +60 -0
- package/src/assets/icons.ts +517 -0
- package/src/components/Blocks/FdsBlockAlert/FdsBlockAlert.stories.ts +94 -0
- package/src/components/Blocks/FdsBlockAlert/FdsBlockAlert.vue +112 -0
- package/src/components/Blocks/FdsBlockAlert/types.ts +12 -0
- package/src/components/Blocks/FdsBlockContent/FdsBlockContent.stories.ts +110 -0
- package/src/components/Blocks/FdsBlockContent/FdsBlockContent.vue +66 -0
- package/src/components/Blocks/FdsBlockContent/types.ts +6 -0
- package/src/components/Blocks/FdsBlockExpander/FdsBlockExpander.stories.ts +123 -0
- package/src/components/Blocks/FdsBlockExpander/FdsBlockExpander.vue +87 -0
- package/src/components/Blocks/FdsBlockExpander/types.ts +8 -0
- package/src/components/Blocks/FdsBlockInfo/FdsBlockInfo.stories.ts +110 -0
- package/src/components/Blocks/FdsBlockInfo/FdsBlockInfo.vue +75 -0
- package/src/components/Blocks/FdsBlockInfo/types.ts +9 -0
- package/src/components/Blocks/FdsBlockLink/FdsBlockLink.css +9 -0
- package/src/components/Blocks/FdsBlockLink/FdsBlockLink.stories.ts +179 -0
- package/src/components/Blocks/FdsBlockLink/FdsBlockLink.vue +149 -0
- package/src/components/Blocks/FdsBlockLink/types.ts +14 -0
- package/src/components/Buttons/ButtonBaseProps.ts +18 -0
- package/src/components/Buttons/FdsButtonCopy/FdsButtonCopy.stories.ts +53 -0
- package/src/components/Buttons/FdsButtonCopy/FdsButtonCopy.vue +87 -0
- package/src/components/Buttons/FdsButtonCopy/types.ts +8 -0
- package/src/components/Buttons/FdsButtonDownload/FdsButtonDownload.stories.ts +111 -0
- package/src/components/Buttons/FdsButtonDownload/FdsButtonDownload.vue +187 -0
- package/src/components/Buttons/FdsButtonIcon/FdsButtonIcon.stories.ts +55 -0
- package/src/components/Buttons/FdsButtonIcon/FdsButtonIcon.vue +57 -0
- package/src/components/Buttons/FdsButtonIcon/types.ts +12 -0
- package/src/components/Buttons/FdsButtonMinor/FdsButtonMinor.stories.ts +68 -0
- package/src/components/Buttons/FdsButtonMinor/FdsButtonMinor.vue +126 -0
- package/src/components/Buttons/FdsButtonPrimary/FdsButtonPrimary.stories.ts +86 -0
- package/src/components/Buttons/FdsButtonPrimary/FdsButtonPrimary.vue +107 -0
- package/src/components/Buttons/FdsButtonSecondary/FdsButtonSecondary.stories.ts +68 -0
- package/src/components/Buttons/FdsButtonSecondary/FdsButtonSecondary.vue +107 -0
- package/src/components/FdsIcon/FdsIcon.stories.ts +69 -0
- package/src/components/FdsIcon/FdsIcon.vue +34 -0
- package/src/components/FdsIcon/types.ts +9 -0
- package/src/components/FdsModal/FdsModal.stories.ts +241 -0
- package/src/components/FdsModal/FdsModal.vue +269 -0
- package/src/components/FdsModal/types.ts +12 -0
- package/src/components/FdsPagination/FdsPagination.stories.ts +109 -0
- package/src/components/FdsPagination/FdsPagination.vue +193 -0
- package/src/components/FdsPagination/types.ts +6 -0
- package/src/components/FdsSearchSelect/FdsSearchSelect.stories.ts +428 -0
- package/src/components/FdsSearchSelect/FdsSearchSelect.vue +621 -0
- package/src/components/FdsSearchSelect/types.ts +25 -0
- package/src/components/FdsSpinner/FdsSpinner.stories.ts +31 -0
- package/src/components/FdsSpinner/FdsSpinner.vue +90 -0
- package/src/components/FdsSpinner/types.ts +6 -0
- package/src/components/FdsSticker/FdsSticker.stories.ts +148 -0
- package/src/components/FdsSticker/FdsSticker.vue +44 -0
- package/src/components/FdsSticker/types.ts +4 -0
- package/src/components/FdsTreeView/FdsTreeView.stories.ts +136 -0
- package/src/components/FdsTreeView/FdsTreeView.vue +162 -0
- package/src/components/FdsTreeView/TreeNode.vue +383 -0
- package/src/components/FdsTreeView/types.ts +141 -0
- package/src/components/FdsTreeView/useTreeState.ts +607 -0
- package/src/components/FdsTreeView/utils.ts +69 -0
- package/src/components/FdsTruncatedText/FdsTruncatedText.stories.ts +78 -0
- package/src/components/FdsTruncatedText/FdsTruncatedText.vue +85 -0
- package/src/components/FdsTruncatedText/types.ts +6 -0
- package/src/components/Form/FdsCheckbox/FdsCheckbox.stories.ts +275 -0
- package/src/components/Form/FdsCheckbox/FdsCheckbox.vue +155 -0
- package/src/components/Form/FdsCheckbox/types.ts +10 -0
- package/src/components/Form/FdsInput/FdsInput.stories.ts +319 -0
- package/src/components/Form/FdsInput/FdsInput.vue +233 -0
- package/src/components/Form/FdsInput/types.ts +25 -0
- package/src/components/Form/FdsRadio/FdsRadio.stories.ts +63 -0
- package/src/components/Form/FdsRadio/FdsRadio.vue +88 -0
- package/src/components/Form/FdsRadio/types.ts +12 -0
- package/src/components/Form/FdsSelect/FdsSelect.stories.ts +78 -0
- package/src/components/Form/FdsSelect/FdsSelect.vue +136 -0
- package/src/components/Form/FdsSelect/types.ts +13 -0
- package/src/components/Form/FdsTextarea/FdsTextarea.stories.ts +52 -0
- package/src/components/Form/FdsTextarea/FdsTextarea.vue +110 -0
- package/src/components/Form/FdsTextarea/types.ts +12 -0
- package/src/components/Table/FdsTable/FdsTable.stories.ts +221 -0
- package/src/components/Table/FdsTable/FdsTable.vue +25 -0
- package/src/components/Table/FdsTable/types.ts +4 -0
- package/src/components/Table/FdsTableHead/FdsTableHead.stories.ts +151 -0
- package/src/components/Table/FdsTableHead/FdsTableHead.vue +54 -0
- package/src/components/Table/FdsTableHead/types.ts +5 -0
- package/src/components/Tabs/FdsTabs/FdsTabs.stories.ts +247 -0
- package/src/components/Tabs/FdsTabs/FdsTabs.vue +27 -0
- package/src/components/Tabs/FdsTabs/types.ts +4 -0
- package/src/components/Tabs/FdsTabsItem/FdsTabsItem.vue +125 -0
- package/src/components/Tabs/FdsTabsItem/types.ts +16 -0
- package/src/components/Typography/FdsHeading/FdsHeading.stories.ts +93 -0
- package/src/components/Typography/FdsHeading/FdsHeading.vue +51 -0
- package/src/components/Typography/FdsHeading/types.ts +5 -0
- package/src/components/Typography/FdsListHeading/FdsListHeading.stories.ts +58 -0
- package/src/components/Typography/FdsListHeading/FdsListHeading.vue +62 -0
- package/src/components/Typography/FdsListHeading/types.ts +8 -0
- package/src/components/Typography/FdsSeparator/FdsSeparator.stories.ts +31 -0
- package/src/components/Typography/FdsSeparator/FdsSeparator.vue +5 -0
- package/src/components/Typography/FdsText/FdsText.stories.ts +66 -0
- package/src/components/Typography/FdsText/FdsText.vue +28 -0
- package/src/components/Typography/FdsText/types.ts +3 -0
- package/src/composables/useBoldQuery.ts +29 -0
- package/src/composables/useElementFinalSize.ts +24 -0
- package/src/composables/useHasSlots.ts +17 -0
- package/src/composables/useIsPid.ts +48 -0
- package/src/docs/Start/Start.mdx +12 -0
- package/src/docs/Usage.md +117 -0
- package/src/fonts.css +28 -0
- package/src/global-components.ts +75 -0
- package/src/index.ts +180 -0
- package/src/main.ts +7 -0
- package/src/slot-styles.css +93 -0
- package/src/style.css +89 -0
- package/src/tokens.css +252 -0
- package/dist/index.d.ts +0 -2
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { useBoldQuery } from '@/composables/useBoldQuery'
|
|
4
|
+
import { isPidString, useIsPid } from '@/composables/useIsPid'
|
|
5
|
+
import FdsIcon from '@/components/FdsIcon/FdsIcon.vue'
|
|
6
|
+
import FdsPagination from '@/components/FdsPagination/FdsPagination.vue'
|
|
7
|
+
import FdsSpinner from '@/components/FdsSpinner/FdsSpinner.vue'
|
|
8
|
+
import FdsInput from '@/components/Form/FdsInput/FdsInput.vue'
|
|
9
|
+
import type { FdsSearchSelectProps } from './types'
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(defineProps<FdsSearchSelectProps>(), {
|
|
12
|
+
items: () => [],
|
|
13
|
+
page: undefined,
|
|
14
|
+
totalPages: undefined,
|
|
15
|
+
totalCount: undefined,
|
|
16
|
+
loading: false,
|
|
17
|
+
searchFields: () => [],
|
|
18
|
+
preserveOrder: false,
|
|
19
|
+
initialValue: '',
|
|
20
|
+
disabled: false,
|
|
21
|
+
dropdownAbsolute: false,
|
|
22
|
+
labelLeft: false,
|
|
23
|
+
label: '',
|
|
24
|
+
meta: undefined,
|
|
25
|
+
singleItemLabel: '',
|
|
26
|
+
searchContext: () => ({ context: '', linkWord: '' }),
|
|
27
|
+
valid: undefined,
|
|
28
|
+
invalidMessage: '',
|
|
29
|
+
noResultPrompt: '',
|
|
30
|
+
borderless: false,
|
|
31
|
+
marginless: false,
|
|
32
|
+
maxListHeight: undefined,
|
|
33
|
+
locale: 'sv',
|
|
34
|
+
clearTrigger: false,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const searchFields = computed(() => props.searchFields ?? [])
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits<{
|
|
40
|
+
(e: 'searchSelected', value: Record<string, unknown> | null): void
|
|
41
|
+
(e: 'paginate', value: number): void
|
|
42
|
+
(e: 'total', value: number): void
|
|
43
|
+
(e: 'change', value: string): void
|
|
44
|
+
}>()
|
|
45
|
+
|
|
46
|
+
const dropdownVisible = ref(false)
|
|
47
|
+
const singleItemName = ref('')
|
|
48
|
+
const searchTerm = ref('')
|
|
49
|
+
const matchingItems = ref<Array<Record<string, unknown>>>([])
|
|
50
|
+
const displayedItems = ref<Array<Record<string, unknown>>>([])
|
|
51
|
+
const selectedItem = ref<Record<string, unknown> | null>(null)
|
|
52
|
+
const focusedIndex = ref<number>(-1)
|
|
53
|
+
const valid = ref<string | undefined>(props.valid)
|
|
54
|
+
|
|
55
|
+
// Use prop.totalCount if provided, otherwise calculate from items.length
|
|
56
|
+
const totalCount = computed(() => {
|
|
57
|
+
if (props.totalCount !== undefined) {
|
|
58
|
+
return props.totalCount
|
|
59
|
+
}
|
|
60
|
+
return props.items?.length || 0
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
watch(
|
|
64
|
+
() => props.valid,
|
|
65
|
+
(newValue) => {
|
|
66
|
+
valid.value = newValue
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const componentRef = ref<HTMLElement | null>(null)
|
|
71
|
+
const inputId = `searchSelectInput-${Math.random().toString(36).slice(2, 9)}`
|
|
72
|
+
|
|
73
|
+
const hasInputValue = computed(() => searchTerm.value.length > 0)
|
|
74
|
+
|
|
75
|
+
const listWrapperClasses = computed(() => [
|
|
76
|
+
'rounded-md box-border overflow-hidden z-4 w-full bg-white mt-2',
|
|
77
|
+
props.marginless ? 'mb-0' : 'mb-8',
|
|
78
|
+
props.borderless ? 'shadow-lg border-none' : 'border border-gray-200',
|
|
79
|
+
props.dropdownAbsolute ? 'absolute right-0 z-50' : 'relative',
|
|
80
|
+
props.maxListHeight && 'overflow-y-scroll',
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
const listItemClasses = computed(() => [
|
|
84
|
+
'block m-0',
|
|
85
|
+
'outline-none! hover:bg-blue_t-100 active:bg-blue_t-200',
|
|
86
|
+
props.borderless && 'border-b border-blue_t-200',
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
const listWrapperStyle = computed(() => {
|
|
90
|
+
if (!props.maxListHeight) return null
|
|
91
|
+
|
|
92
|
+
// Convert to number in case it's passed as a string
|
|
93
|
+
const maxHeight = typeof props.maxListHeight === 'string' ? parseInt(props.maxListHeight, 10) : props.maxListHeight
|
|
94
|
+
|
|
95
|
+
return { maxHeight: `${maxHeight}px` }
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const sortResponse = (response: Array<Record<string, unknown>>) => {
|
|
99
|
+
if (!response.length) return []
|
|
100
|
+
const firstItem = response[0] ?? {}
|
|
101
|
+
const allKeys = [...searchFields.value, ...Object.keys(firstItem).filter((k) => !searchFields.value.includes(k))]
|
|
102
|
+
return response.map((item) => {
|
|
103
|
+
const sorted: Record<string, unknown> = {}
|
|
104
|
+
allKeys.forEach((key) => {
|
|
105
|
+
sorted[key] = item[key] || `${key}: den här uppgiften saknas.`
|
|
106
|
+
})
|
|
107
|
+
return sorted
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const matchesSearchTerm = (item: Record<string, unknown>): boolean => {
|
|
112
|
+
if (!searchTerm.value) return true
|
|
113
|
+
|
|
114
|
+
// When mask is active, use unmasked value (digits only) for searching
|
|
115
|
+
// This ensures we can search even when user has partially typed a PID
|
|
116
|
+
const searchValue = isPid.value ? searchTerm.value.replace(/\D/g, '') : searchTerm.value
|
|
117
|
+
|
|
118
|
+
const searchLower = searchValue.toLowerCase()
|
|
119
|
+
const pidRegex = /^\d{8}[a-zA-Z0-9]{4}$/
|
|
120
|
+
|
|
121
|
+
// If search term matches PID pattern (unmasked), include all items
|
|
122
|
+
if (pidRegex.test(searchValue)) return true
|
|
123
|
+
|
|
124
|
+
// Check if any searchFields field contains the search term
|
|
125
|
+
// For PID fields, also check unmasked version
|
|
126
|
+
return searchFields.value.some((key) => {
|
|
127
|
+
const value = item[key]
|
|
128
|
+
if (!value) return false
|
|
129
|
+
|
|
130
|
+
const stringValue = String(value)
|
|
131
|
+
const valueLower = stringValue.toLowerCase()
|
|
132
|
+
const unmaskedValue = stringValue.replace(/\D/g, '')
|
|
133
|
+
|
|
134
|
+
// Check both masked and unmasked versions
|
|
135
|
+
return valueLower.includes(searchLower) || (unmaskedValue.length > 0 && unmaskedValue.includes(searchValue))
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const filterAndPaginate = () => {
|
|
140
|
+
if (!props.items?.length) {
|
|
141
|
+
matchingItems.value = []
|
|
142
|
+
displayedItems.value = []
|
|
143
|
+
emit('total', 0)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Filter items based on search term
|
|
148
|
+
let sourceData = props.items ?? []
|
|
149
|
+
if (props.preserveOrder && sourceData.length) {
|
|
150
|
+
sourceData = sortResponse(sourceData)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const matchedArray = sourceData.filter((item) => matchesSearchTerm(item))
|
|
154
|
+
matchingItems.value = matchedArray
|
|
155
|
+
|
|
156
|
+
// Never paginate internally - just show all filtered items
|
|
157
|
+
// Parent component is responsible for pagination if needed
|
|
158
|
+
displayedItems.value = matchedArray
|
|
159
|
+
|
|
160
|
+
emit('total', matchingItems.value.length)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const handleClear = () => {
|
|
164
|
+
searchTerm.value = ''
|
|
165
|
+
selectedItem.value = null
|
|
166
|
+
valid.value = 'null'
|
|
167
|
+
emit('searchSelected', null)
|
|
168
|
+
dropdownVisible.value = false
|
|
169
|
+
filterAndPaginate()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
watch(
|
|
173
|
+
() => props.clearTrigger,
|
|
174
|
+
(value) => value && handleClear(),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
watch(
|
|
178
|
+
() => props.items,
|
|
179
|
+
() => {
|
|
180
|
+
filterAndPaginate()
|
|
181
|
+
},
|
|
182
|
+
{ immediate: true },
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const formatPidWithDash = (value: string): string => {
|
|
186
|
+
// Only format if it looks like a personnummer
|
|
187
|
+
if (!isPidString(value)) {
|
|
188
|
+
// If it's not a PID, remove any trailing dash that might be left
|
|
189
|
+
return value.replace(/-$/, '')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Remove all non-digits
|
|
193
|
+
const digits = value.replace(/\D/g, '')
|
|
194
|
+
|
|
195
|
+
// Only add dash if there are digits after the first 8
|
|
196
|
+
if (digits.length > 8) {
|
|
197
|
+
return `${digits.substring(0, 8)}-${digits.substring(8)}`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// If exactly 8 digits or less, return without dash
|
|
201
|
+
return digits
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const debounce = <T extends (...args: any[]) => void>(fn: T, delay: number) => {
|
|
205
|
+
let timeout: ReturnType<typeof setTimeout>
|
|
206
|
+
return (...args: Parameters<T>) => {
|
|
207
|
+
clearTimeout(timeout)
|
|
208
|
+
timeout = setTimeout(() => fn(...args), delay)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const debouncedEmitChange = debounce((value: string) => {
|
|
213
|
+
const formattedValue = formatPidWithDash(value)
|
|
214
|
+
emit('change', formattedValue)
|
|
215
|
+
}, 500)
|
|
216
|
+
|
|
217
|
+
watch(
|
|
218
|
+
() => searchTerm.value,
|
|
219
|
+
(newValue) => {
|
|
220
|
+
filterAndPaginate()
|
|
221
|
+
if (selectedItem.value === null) {
|
|
222
|
+
debouncedEmitChange(newValue)
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
watch(
|
|
228
|
+
() => props.page,
|
|
229
|
+
() => {
|
|
230
|
+
filterAndPaginate()
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
// Reset focused index when items change
|
|
235
|
+
watch(
|
|
236
|
+
() => displayedItems.value,
|
|
237
|
+
() => {
|
|
238
|
+
focusedIndex.value = -1
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
const onClickOutside = (e: PointerEvent) => {
|
|
243
|
+
const target = e.target as Element
|
|
244
|
+
if (componentRef.value?.contains(target)) return
|
|
245
|
+
|
|
246
|
+
dropdownVisible.value = false
|
|
247
|
+
if (props.initialValue === searchTerm.value && props.initialValue.length) {
|
|
248
|
+
valid.value = 'true'
|
|
249
|
+
} else if (searchTerm.value.length > 0 && selectedItem.value === null) {
|
|
250
|
+
valid.value = 'false'
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const totalPages = computed(() => {
|
|
255
|
+
// Only return totalPages if both page and totalPages props are provided
|
|
256
|
+
if (props.page !== undefined && props.totalPages !== undefined) {
|
|
257
|
+
return props.totalPages
|
|
258
|
+
}
|
|
259
|
+
return null
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const handleInput = (e: Event) => {
|
|
263
|
+
const target = e.target as HTMLInputElement
|
|
264
|
+
const { value } = target
|
|
265
|
+
|
|
266
|
+
searchTerm.value = value
|
|
267
|
+
dropdownVisible.value = true
|
|
268
|
+
selectedItem.value = null
|
|
269
|
+
focusedIndex.value = -1
|
|
270
|
+
valid.value = 'null'
|
|
271
|
+
filterAndPaginate()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Personnummer detection using composable
|
|
275
|
+
const { isPid } = useIsPid(searchTerm)
|
|
276
|
+
|
|
277
|
+
// Bold query matches using composable
|
|
278
|
+
const { boldQuery } = useBoldQuery(searchTerm)
|
|
279
|
+
|
|
280
|
+
// Mask configuration for personnummer (yyyymmdd-nnnn)
|
|
281
|
+
const pidMask = computed(() => {
|
|
282
|
+
if (isPid.value) {
|
|
283
|
+
return '00000000-0000'
|
|
284
|
+
}
|
|
285
|
+
return undefined
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const handleMatchingString = (item: Record<string, unknown>): string => {
|
|
289
|
+
const values = searchFields.value.map((key) => String(item[key] || ''))
|
|
290
|
+
|
|
291
|
+
let result = ''
|
|
292
|
+
if (values.length === 1) {
|
|
293
|
+
result = values[0] || ''
|
|
294
|
+
} else {
|
|
295
|
+
// Format PID if second value is 12 digits
|
|
296
|
+
if (values[1]?.length === 12 && parseInt(values[1])) {
|
|
297
|
+
values[1] = formatPidWithDash(values[1])
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (props.preserveOrder) {
|
|
301
|
+
// Combine first two values, then join remaining with <br>
|
|
302
|
+
const combined = `${values[0]} (${values[1]})`
|
|
303
|
+
const rest = values.slice(2).join('<br>')
|
|
304
|
+
result = rest ? `${combined}<br>${rest}` : combined
|
|
305
|
+
} else {
|
|
306
|
+
result = `${values.join(' (')})`
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (searchTerm.value) {
|
|
311
|
+
const formattedTerm = formatPidWithDash(searchTerm.value)
|
|
312
|
+
if (formattedTerm !== searchTerm.value) {
|
|
313
|
+
searchTerm.value = formattedTerm
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const escaped = formattedTerm.replace(/[()]/g, '\\$&')
|
|
317
|
+
if (new RegExp(escaped, 'i').test(result)) {
|
|
318
|
+
return boldQuery(result)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return result
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const handlePagination = (payload: { target: { id: string }; detail: number }) => {
|
|
326
|
+
const newPage = payload.detail
|
|
327
|
+
emit('paginate', newPage)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const selectItem = (item: Record<string, unknown>) => {
|
|
331
|
+
selectedItem.value = item
|
|
332
|
+
valid.value = 'true'
|
|
333
|
+
emit('searchSelected', item)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const handleClick = (e: MouseEvent | KeyboardEvent, item: Record<string, unknown>) => {
|
|
337
|
+
const keyEvent = e as KeyboardEvent
|
|
338
|
+
if (keyEvent.key === 'Enter' || keyEvent.code === 'Space' || e.type === 'mouseup') {
|
|
339
|
+
// Prevent event from bubbling and triggering other handlers
|
|
340
|
+
e.preventDefault()
|
|
341
|
+
e.stopPropagation()
|
|
342
|
+
|
|
343
|
+
const firstField = searchFields.value[0]
|
|
344
|
+
searchTerm.value = (firstField ? (item[firstField] as string) : '') || ''
|
|
345
|
+
selectItem(item)
|
|
346
|
+
focusedIndex.value = -1
|
|
347
|
+
dropdownVisible.value = false
|
|
348
|
+
|
|
349
|
+
// Focus input after a short delay to prevent dropdown from reopening
|
|
350
|
+
setTimeout(() => {
|
|
351
|
+
const input = document.getElementById(inputId) as HTMLInputElement
|
|
352
|
+
if (input) {
|
|
353
|
+
input.focus()
|
|
354
|
+
}
|
|
355
|
+
}, 100)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const handleInputFocus = () => {
|
|
360
|
+
// Only open dropdown if an item hasn't been selected and there are items to show
|
|
361
|
+
if (!selectedItem.value && displayedItems.value.length > 0) {
|
|
362
|
+
dropdownVisible.value = true
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const handleListKeyDown = (e: KeyboardEvent) => {
|
|
367
|
+
// Handle keyboard events when focus is anywhere in the list (including radio buttons)
|
|
368
|
+
// Only handle if the target is a radio button or list item
|
|
369
|
+
const target = e.target as HTMLElement
|
|
370
|
+
const isRadioButton = target.tagName === 'INPUT' && (target as HTMLInputElement).type === 'radio'
|
|
371
|
+
const isListItem = target.closest('li[role="option"]')
|
|
372
|
+
if (isRadioButton || isListItem) {
|
|
373
|
+
handleInputKeyDown(e)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const handleInputKeyDown = (e: KeyboardEvent) => {
|
|
378
|
+
if (!dropdownVisible.value && displayedItems.value.length > 0) {
|
|
379
|
+
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
380
|
+
e.preventDefault()
|
|
381
|
+
dropdownVisible.value = true
|
|
382
|
+
focusedIndex.value = 0
|
|
383
|
+
setTimeout(() => {
|
|
384
|
+
const firstRadioButton = document.getElementById(`search-select-radio-0`) as HTMLInputElement
|
|
385
|
+
if (firstRadioButton) {
|
|
386
|
+
firstRadioButton.focus()
|
|
387
|
+
firstRadioButton.setAttribute('checked', 'true')
|
|
388
|
+
}
|
|
389
|
+
}, 0)
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!dropdownVisible.value) return
|
|
395
|
+
|
|
396
|
+
switch (e.key) {
|
|
397
|
+
case 'ArrowDown':
|
|
398
|
+
e.preventDefault()
|
|
399
|
+
e.stopPropagation()
|
|
400
|
+
if (focusedIndex.value < displayedItems.value.length - 1) {
|
|
401
|
+
focusedIndex.value++
|
|
402
|
+
} else {
|
|
403
|
+
focusedIndex.value = 0
|
|
404
|
+
}
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
case 'ArrowUp':
|
|
408
|
+
e.preventDefault()
|
|
409
|
+
e.stopPropagation()
|
|
410
|
+
if (focusedIndex.value > 0) {
|
|
411
|
+
focusedIndex.value--
|
|
412
|
+
} else {
|
|
413
|
+
focusedIndex.value = displayedItems.value.length - 1
|
|
414
|
+
}
|
|
415
|
+
break
|
|
416
|
+
|
|
417
|
+
case 'Enter':
|
|
418
|
+
e.preventDefault()
|
|
419
|
+
if (focusedIndex.value >= 0 && focusedIndex.value < displayedItems.value.length) {
|
|
420
|
+
const focusedItem = displayedItems.value[focusedIndex.value]
|
|
421
|
+
if (focusedItem) {
|
|
422
|
+
handleClick(e, focusedItem)
|
|
423
|
+
}
|
|
424
|
+
} else if (displayedItems.value.length === 1) {
|
|
425
|
+
const onlyItem = displayedItems.value[0]
|
|
426
|
+
if (onlyItem) {
|
|
427
|
+
handleClick(e, onlyItem)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
break
|
|
431
|
+
|
|
432
|
+
case 'Escape':
|
|
433
|
+
e.preventDefault()
|
|
434
|
+
dropdownVisible.value = false
|
|
435
|
+
focusedIndex.value = -1
|
|
436
|
+
const input = document.getElementById(inputId) as HTMLInputElement
|
|
437
|
+
input?.focus()
|
|
438
|
+
break
|
|
439
|
+
|
|
440
|
+
case 'Tab':
|
|
441
|
+
// Let default tab behavior handle focus navigation
|
|
442
|
+
// When list is open, first list-item has tabindex="0" so Tab will go there
|
|
443
|
+
// When list is closed, Tab will go to next element normally
|
|
444
|
+
if (!dropdownVisible.value) {
|
|
445
|
+
dropdownVisible.value = false
|
|
446
|
+
focusedIndex.value = -1
|
|
447
|
+
}
|
|
448
|
+
break
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
onMounted(() => {
|
|
453
|
+
if (props.initialValue) {
|
|
454
|
+
searchTerm.value = props.initialValue
|
|
455
|
+
valid.value = 'true'
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
window.addEventListener('mouseup', onClickOutside as EventListener)
|
|
459
|
+
|
|
460
|
+
nextTick(() => {
|
|
461
|
+
const input = document.getElementById(inputId)
|
|
462
|
+
if (input) {
|
|
463
|
+
input.addEventListener('keydown', handleInputKeyDown)
|
|
464
|
+
input.addEventListener('focus', handleInputFocus)
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
filterAndPaginate()
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
onBeforeUnmount(() => {
|
|
472
|
+
window.removeEventListener('mouseup', onClickOutside as EventListener)
|
|
473
|
+
const input = document.getElementById(inputId)
|
|
474
|
+
if (input) {
|
|
475
|
+
input.removeEventListener('keydown', handleInputKeyDown)
|
|
476
|
+
input.removeEventListener('focus', handleInputFocus)
|
|
477
|
+
}
|
|
478
|
+
})
|
|
479
|
+
</script>
|
|
480
|
+
|
|
481
|
+
<template>
|
|
482
|
+
<div
|
|
483
|
+
ref="componentRef"
|
|
484
|
+
class="fds-search-select block mb-6"
|
|
485
|
+
>
|
|
486
|
+
<div class="relative block">
|
|
487
|
+
<div
|
|
488
|
+
v-if="!singleItemName.length"
|
|
489
|
+
class="relative"
|
|
490
|
+
>
|
|
491
|
+
<div class="relative">
|
|
492
|
+
<FdsInput
|
|
493
|
+
:label="label"
|
|
494
|
+
:meta="meta ?? undefined"
|
|
495
|
+
:labelLeft="labelLeft"
|
|
496
|
+
class="relative mb-0! w-full!"
|
|
497
|
+
:valid="valid"
|
|
498
|
+
:invalidMessage="invalidMessage"
|
|
499
|
+
:disabled="disabled"
|
|
500
|
+
:locale="locale"
|
|
501
|
+
type="search"
|
|
502
|
+
:value="searchTerm"
|
|
503
|
+
:id="inputId"
|
|
504
|
+
:clearButton="!!searchTerm"
|
|
505
|
+
:mask="pidMask"
|
|
506
|
+
:maskOptions="{ lazy: true }"
|
|
507
|
+
@input="handleInput"
|
|
508
|
+
@clearInput="handleClear"
|
|
509
|
+
/>
|
|
510
|
+
<FdsIcon
|
|
511
|
+
v-if="!disabled && !hasInputValue"
|
|
512
|
+
name="search"
|
|
513
|
+
:size="24"
|
|
514
|
+
class="absolute right-3 bottom-6 translate-y-1/2 fill-blue-500"
|
|
515
|
+
/>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
<div v-else>
|
|
519
|
+
<div class="font-bold mb-2 tracking-wide">{{ singleItemLabel }}</div>
|
|
520
|
+
<div>{{ singleItemName }}</div>
|
|
521
|
+
</div>
|
|
522
|
+
|
|
523
|
+
<!-- Dropdown List -->
|
|
524
|
+
<div
|
|
525
|
+
v-if="dropdownVisible && !singleItemName.length && !disabled"
|
|
526
|
+
:class="listWrapperClasses"
|
|
527
|
+
:style="listWrapperStyle"
|
|
528
|
+
aria-haspopup="listbox"
|
|
529
|
+
aria-expanded="false"
|
|
530
|
+
aria-controls="select-dropdown"
|
|
531
|
+
>
|
|
532
|
+
<!-- Loading -->
|
|
533
|
+
<div
|
|
534
|
+
v-if="loading"
|
|
535
|
+
class="flex justify-center p-4"
|
|
536
|
+
>
|
|
537
|
+
<FdsSpinner
|
|
538
|
+
color="blue"
|
|
539
|
+
size="48px"
|
|
540
|
+
/>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
<!-- Results -->
|
|
544
|
+
<template v-else-if="displayedItems && displayedItems.length">
|
|
545
|
+
<!-- Header -->
|
|
546
|
+
<div
|
|
547
|
+
v-if="searchTerm && searchTerm.length"
|
|
548
|
+
class="block m-0 font-light p-6 border-b border-gray-200 rounded-t-md"
|
|
549
|
+
>
|
|
550
|
+
{{ displayedItems.length }} {{ searchContext.linkWord }} {{ totalCount }}
|
|
551
|
+
{{ searchContext.context }}
|
|
552
|
+
</div>
|
|
553
|
+
<div
|
|
554
|
+
v-else-if="!searchTerm.length"
|
|
555
|
+
class="block m-0 font-light p-4 border-b border-gray-200 rounded-t-md"
|
|
556
|
+
>
|
|
557
|
+
{{ totalCount }} {{ searchContext.context }}
|
|
558
|
+
</div>
|
|
559
|
+
|
|
560
|
+
<!-- List -->
|
|
561
|
+
<ul
|
|
562
|
+
class="block m-0 list-none p-0"
|
|
563
|
+
role="listbox"
|
|
564
|
+
id="select-dropdown"
|
|
565
|
+
@keydown="handleListKeyDown"
|
|
566
|
+
>
|
|
567
|
+
<li
|
|
568
|
+
v-for="(item, index) in displayedItems"
|
|
569
|
+
:key="index"
|
|
570
|
+
:id="`search-select-item-${index}`"
|
|
571
|
+
class="block m-0 hover:outline-none focus:outline-2 focus:outline-blue-500 -outline-offset-2"
|
|
572
|
+
:class="[focusedIndex === index && 'outline-dashed outline-2 outline-blue-500 -outline-offset-2 ']"
|
|
573
|
+
role="option"
|
|
574
|
+
:aria-selected="focusedIndex === index"
|
|
575
|
+
:tabindex="dropdownVisible && focusedIndex === index ? 0 : -1"
|
|
576
|
+
@mouseup="(e) => handleClick(e, item)"
|
|
577
|
+
@mouseenter="focusedIndex = index"
|
|
578
|
+
@keydown.enter="(e) => handleClick(e, item)"
|
|
579
|
+
@keydown.space.prevent="(e) => handleClick(e, item)"
|
|
580
|
+
>
|
|
581
|
+
<input
|
|
582
|
+
type="radio"
|
|
583
|
+
:id="`search-select-radio-${index}`"
|
|
584
|
+
name="social-account"
|
|
585
|
+
class="absolute left-0 opacity-0"
|
|
586
|
+
:checked="focusedIndex === index"
|
|
587
|
+
:key="`radio-${index}-${focusedIndex}`"
|
|
588
|
+
tabindex="-1"
|
|
589
|
+
/>
|
|
590
|
+
<label
|
|
591
|
+
:for="`search-select-radio-${index}`"
|
|
592
|
+
class="block p-4 text-gray-700 cursor-pointer no-underline"
|
|
593
|
+
:class="listItemClasses"
|
|
594
|
+
v-html="handleMatchingString(item)"
|
|
595
|
+
></label>
|
|
596
|
+
</li>
|
|
597
|
+
</ul>
|
|
598
|
+
|
|
599
|
+
<!-- Pagination -->
|
|
600
|
+
<FdsPagination
|
|
601
|
+
v-if="page !== undefined && totalPages !== null && totalPages > 1"
|
|
602
|
+
:current="page"
|
|
603
|
+
:max="totalPages"
|
|
604
|
+
@paginate="handlePagination"
|
|
605
|
+
class="my-4! px-2"
|
|
606
|
+
/>
|
|
607
|
+
</template>
|
|
608
|
+
|
|
609
|
+
<!-- No Results -->
|
|
610
|
+
<ul
|
|
611
|
+
v-else-if="!loading"
|
|
612
|
+
class="block m-0 list-none p-0"
|
|
613
|
+
>
|
|
614
|
+
<li class="p-4">
|
|
615
|
+
{{ noResultPrompt }}
|
|
616
|
+
</li>
|
|
617
|
+
</ul>
|
|
618
|
+
</div>
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface FdsSearchSelectProps {
|
|
2
|
+
items: Array<Record<string, unknown>>
|
|
3
|
+
page?: number
|
|
4
|
+
totalPages?: number
|
|
5
|
+
totalCount?: number
|
|
6
|
+
loading?: boolean
|
|
7
|
+
searchFields?: string[]
|
|
8
|
+
preserveOrder?: boolean
|
|
9
|
+
initialValue?: string
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
dropdownAbsolute?: boolean
|
|
12
|
+
labelLeft?: boolean
|
|
13
|
+
label?: string
|
|
14
|
+
meta?: string | null
|
|
15
|
+
singleItemLabel?: string
|
|
16
|
+
searchContext?: { context: string; linkWord: string }
|
|
17
|
+
valid?: string
|
|
18
|
+
invalidMessage?: string
|
|
19
|
+
noResultPrompt?: string
|
|
20
|
+
borderless?: boolean
|
|
21
|
+
marginless?: boolean
|
|
22
|
+
maxListHeight?: number | string
|
|
23
|
+
locale?: 'sv' | 'en'
|
|
24
|
+
clearTrigger?: boolean
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
2
|
+
import FdsSpinner from './FdsSpinner.vue'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof FdsSpinner> = {
|
|
5
|
+
title: 'FDS/FdsSpinner',
|
|
6
|
+
component: FdsSpinner,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
size: { control: { type: 'text' } },
|
|
10
|
+
color: { control: { type: 'inline-radio' }, options: ['inherit', 'black', 'white', 'blue'] },
|
|
11
|
+
label: { control: { type: 'text' } },
|
|
12
|
+
labelPosition: { control: { type: 'inline-radio' }, options: ['right', 'bottom'] },
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
size: '48px',
|
|
16
|
+
color: 'inherit',
|
|
17
|
+
label: '',
|
|
18
|
+
labelPosition: 'bottom',
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default meta
|
|
23
|
+
type Story = StoryObj<typeof meta>
|
|
24
|
+
|
|
25
|
+
export const Basic: Story = {
|
|
26
|
+
render: (args) => ({
|
|
27
|
+
components: { FdsSpinner },
|
|
28
|
+
setup: () => ({ args }),
|
|
29
|
+
template: '<FdsSpinner v-bind="args" />',
|
|
30
|
+
}),
|
|
31
|
+
}
|