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.
Files changed (121) hide show
  1. package/components.d.ts +8 -0
  2. package/configs/tsconfig.base.json +2 -1
  3. package/dist/fds-vue-core.cjs.js +35 -15
  4. package/dist/fds-vue-core.cjs.js.map +1 -1
  5. package/dist/fds-vue-core.es.js +35 -15
  6. package/dist/fds-vue-core.es.js.map +1 -1
  7. package/dist/global-components.d.ts +35 -33
  8. package/package.json +23 -21
  9. package/src/.DS_Store +0 -0
  10. package/src/App.vue +133 -0
  11. package/src/apply.css +60 -0
  12. package/src/assets/icons.ts +517 -0
  13. package/src/components/Blocks/FdsBlockAlert/FdsBlockAlert.stories.ts +94 -0
  14. package/src/components/Blocks/FdsBlockAlert/FdsBlockAlert.vue +112 -0
  15. package/src/components/Blocks/FdsBlockAlert/types.ts +12 -0
  16. package/src/components/Blocks/FdsBlockContent/FdsBlockContent.stories.ts +110 -0
  17. package/src/components/Blocks/FdsBlockContent/FdsBlockContent.vue +66 -0
  18. package/src/components/Blocks/FdsBlockContent/types.ts +6 -0
  19. package/src/components/Blocks/FdsBlockExpander/FdsBlockExpander.stories.ts +123 -0
  20. package/src/components/Blocks/FdsBlockExpander/FdsBlockExpander.vue +87 -0
  21. package/src/components/Blocks/FdsBlockExpander/types.ts +8 -0
  22. package/src/components/Blocks/FdsBlockInfo/FdsBlockInfo.stories.ts +110 -0
  23. package/src/components/Blocks/FdsBlockInfo/FdsBlockInfo.vue +75 -0
  24. package/src/components/Blocks/FdsBlockInfo/types.ts +9 -0
  25. package/src/components/Blocks/FdsBlockLink/FdsBlockLink.css +9 -0
  26. package/src/components/Blocks/FdsBlockLink/FdsBlockLink.stories.ts +179 -0
  27. package/src/components/Blocks/FdsBlockLink/FdsBlockLink.vue +149 -0
  28. package/src/components/Blocks/FdsBlockLink/types.ts +14 -0
  29. package/src/components/Buttons/ButtonBaseProps.ts +18 -0
  30. package/src/components/Buttons/FdsButtonCopy/FdsButtonCopy.stories.ts +53 -0
  31. package/src/components/Buttons/FdsButtonCopy/FdsButtonCopy.vue +87 -0
  32. package/src/components/Buttons/FdsButtonCopy/types.ts +8 -0
  33. package/src/components/Buttons/FdsButtonDownload/FdsButtonDownload.stories.ts +111 -0
  34. package/src/components/Buttons/FdsButtonDownload/FdsButtonDownload.vue +187 -0
  35. package/src/components/Buttons/FdsButtonIcon/FdsButtonIcon.stories.ts +55 -0
  36. package/src/components/Buttons/FdsButtonIcon/FdsButtonIcon.vue +57 -0
  37. package/src/components/Buttons/FdsButtonIcon/types.ts +12 -0
  38. package/src/components/Buttons/FdsButtonMinor/FdsButtonMinor.stories.ts +68 -0
  39. package/src/components/Buttons/FdsButtonMinor/FdsButtonMinor.vue +126 -0
  40. package/src/components/Buttons/FdsButtonPrimary/FdsButtonPrimary.stories.ts +86 -0
  41. package/src/components/Buttons/FdsButtonPrimary/FdsButtonPrimary.vue +107 -0
  42. package/src/components/Buttons/FdsButtonSecondary/FdsButtonSecondary.stories.ts +68 -0
  43. package/src/components/Buttons/FdsButtonSecondary/FdsButtonSecondary.vue +107 -0
  44. package/src/components/FdsIcon/FdsIcon.stories.ts +69 -0
  45. package/src/components/FdsIcon/FdsIcon.vue +34 -0
  46. package/src/components/FdsIcon/types.ts +9 -0
  47. package/src/components/FdsModal/FdsModal.stories.ts +241 -0
  48. package/src/components/FdsModal/FdsModal.vue +269 -0
  49. package/src/components/FdsModal/types.ts +12 -0
  50. package/src/components/FdsPagination/FdsPagination.stories.ts +109 -0
  51. package/src/components/FdsPagination/FdsPagination.vue +193 -0
  52. package/src/components/FdsPagination/types.ts +6 -0
  53. package/src/components/FdsSearchSelect/FdsSearchSelect.stories.ts +428 -0
  54. package/src/components/FdsSearchSelect/FdsSearchSelect.vue +621 -0
  55. package/src/components/FdsSearchSelect/types.ts +25 -0
  56. package/src/components/FdsSpinner/FdsSpinner.stories.ts +31 -0
  57. package/src/components/FdsSpinner/FdsSpinner.vue +90 -0
  58. package/src/components/FdsSpinner/types.ts +6 -0
  59. package/src/components/FdsSticker/FdsSticker.stories.ts +148 -0
  60. package/src/components/FdsSticker/FdsSticker.vue +44 -0
  61. package/src/components/FdsSticker/types.ts +4 -0
  62. package/src/components/FdsTreeView/FdsTreeView.stories.ts +136 -0
  63. package/src/components/FdsTreeView/FdsTreeView.vue +162 -0
  64. package/src/components/FdsTreeView/TreeNode.vue +383 -0
  65. package/src/components/FdsTreeView/types.ts +141 -0
  66. package/src/components/FdsTreeView/useTreeState.ts +607 -0
  67. package/src/components/FdsTreeView/utils.ts +69 -0
  68. package/src/components/FdsTruncatedText/FdsTruncatedText.stories.ts +78 -0
  69. package/src/components/FdsTruncatedText/FdsTruncatedText.vue +85 -0
  70. package/src/components/FdsTruncatedText/types.ts +6 -0
  71. package/src/components/Form/FdsCheckbox/FdsCheckbox.stories.ts +275 -0
  72. package/src/components/Form/FdsCheckbox/FdsCheckbox.vue +155 -0
  73. package/src/components/Form/FdsCheckbox/types.ts +10 -0
  74. package/src/components/Form/FdsInput/FdsInput.stories.ts +319 -0
  75. package/src/components/Form/FdsInput/FdsInput.vue +233 -0
  76. package/src/components/Form/FdsInput/types.ts +25 -0
  77. package/src/components/Form/FdsRadio/FdsRadio.stories.ts +63 -0
  78. package/src/components/Form/FdsRadio/FdsRadio.vue +88 -0
  79. package/src/components/Form/FdsRadio/types.ts +12 -0
  80. package/src/components/Form/FdsSelect/FdsSelect.stories.ts +78 -0
  81. package/src/components/Form/FdsSelect/FdsSelect.vue +136 -0
  82. package/src/components/Form/FdsSelect/types.ts +13 -0
  83. package/src/components/Form/FdsTextarea/FdsTextarea.stories.ts +52 -0
  84. package/src/components/Form/FdsTextarea/FdsTextarea.vue +110 -0
  85. package/src/components/Form/FdsTextarea/types.ts +12 -0
  86. package/src/components/Table/FdsTable/FdsTable.stories.ts +221 -0
  87. package/src/components/Table/FdsTable/FdsTable.vue +25 -0
  88. package/src/components/Table/FdsTable/types.ts +4 -0
  89. package/src/components/Table/FdsTableHead/FdsTableHead.stories.ts +151 -0
  90. package/src/components/Table/FdsTableHead/FdsTableHead.vue +54 -0
  91. package/src/components/Table/FdsTableHead/types.ts +5 -0
  92. package/src/components/Tabs/FdsTabs/FdsTabs.stories.ts +247 -0
  93. package/src/components/Tabs/FdsTabs/FdsTabs.vue +27 -0
  94. package/src/components/Tabs/FdsTabs/types.ts +4 -0
  95. package/src/components/Tabs/FdsTabsItem/FdsTabsItem.vue +125 -0
  96. package/src/components/Tabs/FdsTabsItem/types.ts +16 -0
  97. package/src/components/Typography/FdsHeading/FdsHeading.stories.ts +93 -0
  98. package/src/components/Typography/FdsHeading/FdsHeading.vue +51 -0
  99. package/src/components/Typography/FdsHeading/types.ts +5 -0
  100. package/src/components/Typography/FdsListHeading/FdsListHeading.stories.ts +58 -0
  101. package/src/components/Typography/FdsListHeading/FdsListHeading.vue +62 -0
  102. package/src/components/Typography/FdsListHeading/types.ts +8 -0
  103. package/src/components/Typography/FdsSeparator/FdsSeparator.stories.ts +31 -0
  104. package/src/components/Typography/FdsSeparator/FdsSeparator.vue +5 -0
  105. package/src/components/Typography/FdsText/FdsText.stories.ts +66 -0
  106. package/src/components/Typography/FdsText/FdsText.vue +28 -0
  107. package/src/components/Typography/FdsText/types.ts +3 -0
  108. package/src/composables/useBoldQuery.ts +29 -0
  109. package/src/composables/useElementFinalSize.ts +24 -0
  110. package/src/composables/useHasSlots.ts +17 -0
  111. package/src/composables/useIsPid.ts +48 -0
  112. package/src/docs/Start/Start.mdx +12 -0
  113. package/src/docs/Usage.md +117 -0
  114. package/src/fonts.css +28 -0
  115. package/src/global-components.ts +75 -0
  116. package/src/index.ts +180 -0
  117. package/src/main.ts +7 -0
  118. package/src/slot-styles.css +93 -0
  119. package/src/style.css +89 -0
  120. package/src/tokens.css +252 -0
  121. 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
+ }