@xen-orchestra/web-core 0.18.0 → 0.20.0

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 (50) hide show
  1. package/lib/components/backdrop/VtsBackdrop.vue +1 -1
  2. package/lib/components/column/VtsColumn.vue +21 -0
  3. package/lib/components/columns/VtsColumns.vue +38 -0
  4. package/lib/components/copy-button/VtsCopyButton.vue +29 -0
  5. package/lib/components/enabled-state/VtsEnabledState.vue +23 -0
  6. package/lib/components/icon/VtsIcon.vue +9 -1
  7. package/lib/components/input-wrapper/VtsInputWrapper.vue +1 -0
  8. package/lib/components/layout/VtsLayoutSidebar.vue +1 -1
  9. package/lib/components/quick-info-column/VtsQuickInfoColumn.vue +1 -1
  10. package/lib/components/quick-info-row/VtsQuickInfoRow.vue +26 -7
  11. package/lib/components/relative-time/VtsRelativeTime.vue +18 -0
  12. package/lib/components/select/VtsOption.vue +24 -0
  13. package/lib/components/select/VtsSelect.vue +96 -0
  14. package/lib/components/state-hero/VtsLoadingHero.vue +45 -4
  15. package/lib/components/tree/VtsTreeItem.vue +11 -1
  16. package/lib/components/ui/alert/UiAlert.vue +105 -0
  17. package/lib/components/ui/circle-progress-bar/UiCircleProgressBar.vue +212 -0
  18. package/lib/components/ui/dropdown/UiDropdownList.vue +10 -2
  19. package/lib/components/ui/head-bar/UiHeadBar.vue +2 -2
  20. package/lib/components/ui/info/UiInfo.vue +5 -3
  21. package/lib/components/ui/input/UiInput.vue +126 -109
  22. package/lib/composables/chart-theme.composable.ts +3 -3
  23. package/lib/composables/relative-time.composable.ts +1 -1
  24. package/lib/i18n.ts +4 -0
  25. package/lib/locales/cs.json +65 -18
  26. package/lib/locales/en.json +65 -1
  27. package/lib/locales/es.json +60 -13
  28. package/lib/locales/fa.json +59 -12
  29. package/lib/locales/fr.json +67 -3
  30. package/lib/locales/it.json +145 -7
  31. package/lib/locales/nl.json +502 -0
  32. package/lib/locales/ru.json +91 -1
  33. package/lib/locales/sv.json +75 -19
  34. package/lib/packages/collection/README.md +172 -0
  35. package/lib/packages/collection/create-collection.ts +74 -0
  36. package/lib/packages/collection/create-item.ts +39 -0
  37. package/lib/packages/collection/guess-item-id.ts +26 -0
  38. package/lib/packages/collection/index.ts +2 -0
  39. package/lib/packages/collection/types.ts +57 -0
  40. package/lib/packages/collection/use-collection.ts +47 -0
  41. package/lib/packages/collection/use-flag-registry.ts +64 -0
  42. package/lib/packages/form-select/README.md +96 -0
  43. package/lib/packages/form-select/index.ts +2 -0
  44. package/lib/packages/form-select/types.ts +75 -0
  45. package/lib/packages/form-select/use-form-option-controller.ts +50 -0
  46. package/lib/packages/form-select/use-form-select-controller.ts +205 -0
  47. package/lib/packages/form-select/use-form-select-keyboard-navigation.ts +157 -0
  48. package/lib/packages/form-select/use-form-select.ts +193 -0
  49. package/lib/stores/sidebar.store.ts +14 -1
  50. package/package.json +1 -1
@@ -0,0 +1,57 @@
1
+ import type { ComputedRef, Reactive } from 'vue'
2
+
3
+ export type CollectionConfigProperties = Record<string, unknown> & { id?: unknown }
4
+
5
+ export type CollectionConfigFlags<TFlag extends string> = TFlag[] | Record<TFlag, { multiple?: boolean }>
6
+
7
+ export type CollectionItem<
8
+ TSource,
9
+ TFlag extends string = never,
10
+ TProperties extends CollectionConfigProperties = Record<string, never>,
11
+ > = {
12
+ id: GuessItemId<TSource, Reactive<TProperties>>
13
+ source: TSource
14
+ flags: Record<TFlag, boolean>
15
+ properties: Reactive<TProperties>
16
+ toggleFlag: (flag: TFlag, forcedValue?: boolean) => void
17
+ }
18
+
19
+ export type FlagRegistry<TFlag extends string> = {
20
+ isFlagged: (id: PropertyKey, flag: TFlag) => boolean
21
+ isFlagDefined: (flag: TFlag) => boolean
22
+ toggleFlag: (id: PropertyKey, flag: TFlag, forcedValue?: boolean) => void
23
+ clearFlag: (flag: TFlag) => void
24
+ isMultipleAllowed: (flag: TFlag) => boolean
25
+ assertFlag: (flag: TFlag) => void
26
+ }
27
+
28
+ export type UseFlagReturn<TSource, TFlag extends string, TProperties extends CollectionConfigProperties> = {
29
+ items: ComputedRef<CollectionItem<TSource, TFlag, TProperties>[]>
30
+ ids: ComputedRef<GuessItemId<TSource, TProperties>[]>
31
+ count: ComputedRef<number>
32
+ areAllOn: ComputedRef<boolean>
33
+ areSomeOn: ComputedRef<boolean>
34
+ areNoneOn: ComputedRef<boolean>
35
+ toggle: (id: GuessItemId<TSource, TProperties>, forcedValue?: boolean) => void
36
+ toggleAll: (forcedValue?: boolean) => void
37
+ useSubset: (
38
+ filter: (item: CollectionItem<TSource, TFlag, TProperties>) => boolean
39
+ ) => Collection<TSource, TFlag, TProperties>
40
+ }
41
+
42
+ export type Collection<TSource, TFlag extends string, TProperties extends CollectionConfigProperties> = {
43
+ items: ComputedRef<CollectionItem<TSource, TFlag, TProperties>[]>
44
+ count: ComputedRef<number>
45
+ useFlag: (flag: TFlag) => UseFlagReturn<TSource, TFlag, TProperties>
46
+ useSubset: (
47
+ filter: (item: CollectionItem<TSource, TFlag, TProperties>) => boolean
48
+ ) => Collection<TSource, TFlag, TProperties>
49
+ }
50
+
51
+ type AssertId<TId> = TId extends PropertyKey ? TId : never
52
+
53
+ export type GuessItemId<TSource, TProperties> = TProperties extends { id: infer TId }
54
+ ? AssertId<TId>
55
+ : TSource extends { id: infer TId }
56
+ ? AssertId<TId>
57
+ : never
@@ -0,0 +1,47 @@
1
+ import { createCollection } from '@core/packages/collection/create-collection.ts'
2
+ import { createItem } from '@core/packages/collection/create-item.ts'
3
+ import type { Collection, CollectionConfigFlags, CollectionConfigProperties } from '@core/packages/collection/types.ts'
4
+ import { useFlagRegistry } from '@core/packages/collection/use-flag-registry.ts'
5
+ import { computed, type MaybeRefOrGetter, toValue } from 'vue'
6
+
7
+ export function useCollection<
8
+ TSource extends { id: unknown },
9
+ TFlag extends string = never,
10
+ TProperties extends CollectionConfigProperties = { id?: unknown },
11
+ >(
12
+ sources: MaybeRefOrGetter<TSource[]>,
13
+ config?: {
14
+ flags?: CollectionConfigFlags<TFlag>
15
+ properties?: (source: TSource) => TProperties
16
+ }
17
+ ): Collection<TSource, TFlag, TProperties>
18
+
19
+ export function useCollection<
20
+ TSource,
21
+ TFlag extends string = never,
22
+ TProperties extends CollectionConfigProperties & { id: unknown } = never,
23
+ >(
24
+ sources: MaybeRefOrGetter<TSource[]>,
25
+ config: {
26
+ flags?: CollectionConfigFlags<TFlag>
27
+ properties: (source: TSource) => TProperties
28
+ }
29
+ ): Collection<TSource, TFlag, TProperties>
30
+
31
+ export function useCollection<
32
+ TSource,
33
+ TFlag extends string = never,
34
+ TProperties extends CollectionConfigProperties = { id?: unknown },
35
+ >(
36
+ sources: MaybeRefOrGetter<TSource[]>,
37
+ config?: {
38
+ flags?: CollectionConfigFlags<TFlag>
39
+ properties?: (source: TSource) => TProperties
40
+ }
41
+ ): Collection<TSource, TFlag, TProperties> {
42
+ const flagRegistry = useFlagRegistry(config?.flags)
43
+
44
+ const items = computed(() => toValue(sources).map(source => createItem(source, config?.properties, flagRegistry)))
45
+
46
+ return createCollection(items, flagRegistry)
47
+ }
@@ -0,0 +1,64 @@
1
+ import type { CollectionConfigFlags, FlagRegistry } from '@core/packages/collection/types.ts'
2
+ import { reactive } from 'vue'
3
+
4
+ export function useFlagRegistry<TFlag extends string>(
5
+ config: CollectionConfigFlags<TFlag> = [] as TFlag[]
6
+ ): FlagRegistry<TFlag> {
7
+ const registry = reactive(new Map<TFlag, Set<PropertyKey>>())
8
+
9
+ const flags = Array.isArray(config) ? Object.fromEntries(config.map(flag => [flag, { multiple: true }])) : config
10
+
11
+ function isFlagDefined(flag: TFlag) {
12
+ return Object.prototype.hasOwnProperty.call(flags, flag)
13
+ }
14
+
15
+ function assertFlag(flag: TFlag) {
16
+ if (!isFlagDefined(flag)) {
17
+ throw new Error(`Flag "${flag}" is not defined.`)
18
+ }
19
+ }
20
+
21
+ function isFlagged(id: PropertyKey, flag: TFlag) {
22
+ assertFlag(flag)
23
+
24
+ return registry.get(flag)?.has(id) ?? false
25
+ }
26
+
27
+ function toggleFlag(id: PropertyKey, flag: TFlag, forcedValue = !isFlagged(id, flag)) {
28
+ assertFlag(flag)
29
+
30
+ if (!registry.has(flag)) {
31
+ registry.set(flag, new Set())
32
+ }
33
+
34
+ if (forcedValue) {
35
+ if (!isMultipleAllowed(flag)) {
36
+ clearFlag(flag)
37
+ }
38
+ registry.get(flag)!.add(id)
39
+ } else {
40
+ registry.get(flag)!.delete(id)
41
+ }
42
+ }
43
+
44
+ function clearFlag(flag: TFlag) {
45
+ assertFlag(flag)
46
+
47
+ registry.set(flag, new Set())
48
+ }
49
+
50
+ function isMultipleAllowed(flag: TFlag) {
51
+ assertFlag(flag)
52
+
53
+ return flags[flag]?.multiple ?? false
54
+ }
55
+
56
+ return {
57
+ isFlagged,
58
+ isFlagDefined,
59
+ toggleFlag,
60
+ clearFlag,
61
+ isMultipleAllowed,
62
+ assertFlag,
63
+ }
64
+ }
@@ -0,0 +1,96 @@
1
+ # `useFormSelect` composable
2
+
3
+ This composable manages a collection of filterable form options to be used with `VtsSelect` component.
4
+
5
+ ## Usage
6
+
7
+ ```typescript
8
+ const { searchTerm, options, selectedOptions, selectedValues } = useFormSelect(sources, {
9
+ properties: source => ({
10
+ value: source.id, // optional if source has a `value` or `ìd` property
11
+ label: source.name, // optional if source has a `label` property
12
+ searchableTerm: [source.name, source.code], // optional, defaults to the label
13
+ disabled: source.state == 'offline', // optional, defaults to false
14
+ }),
15
+ multiple: false,
16
+ })
17
+ ```
18
+
19
+ ## Parameters
20
+
21
+ | | Required | Type | Default | Description |
22
+ | --------- | :------: | ----------------------------- | ------- | --------------------------------------------------------- |
23
+ | `sources` | ✓ | `MaybeRefOrGetter<TSource[]>` | | Array of source objects to be converted into form options |
24
+ | `config` | ✓ | (see below) | | Configuration |
25
+
26
+ ### `config` object
27
+
28
+ | | Required | Type | Default | Description |
29
+ | --------------------------- | :------: | ---------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------- |
30
+ | `properties` | ~ | Record<string, unknown> | | Object containing custom properties for each option |
31
+ | `properties.value` | ~ | `TValue` | | A unique value for each option. Required if `TSource` doesn't have an `id` or `value` |
32
+ | `properties.label` | ~ | `string` | | A human-readable label for each option. Required if `TSource` doesn't have a `label` |
33
+ | `properties.searchableTerm` | | `MaybeArray<string>` | Same as `label` | Searchable term(s) for each option for filtering |
34
+ | `properties.disabled` | | `boolean` | `false` | Determines if an option should be disabled |
35
+ | `multiple` | | `boolean` | `false` | Whether multiple options can be selected simultaneously |
36
+ | `selectedLabel` | | `(count: number, labels: string[]) => string \| undefined` | | Function to format the label for selected options. Default label is `labels.join(', ')` |
37
+
38
+ ## Return Value
39
+
40
+ | | Type | Description |
41
+ | ----------------- | --------------------------- | ---------------------------------------------------- |
42
+ | `searchTerm` | `Ref<string>` | Reactive reference to control the search/filter term |
43
+ | `allOptions` | `ComputedRef<FormOption[]>` | All options, regardless of search filtering |
44
+ | `options` | `ComputedRef<FormOption[]>` | Options filtered by the current search term |
45
+ | `selectedOptions` | `ComputedRef<FormOption[]>` | Options that are currently selected |
46
+ | `selectedValues` | `ComputedRef<TValue[]>` | Values of options that are currently selected |
47
+ | `selectedLabel` | `ComputedRef<string>` | Label for the selected options |
48
+
49
+ ## `FormOption` object
50
+
51
+ Each item in the `options` array is a `FormOption` object with these properties:
52
+
53
+ | | Type | Description |
54
+ | ----------------------- | ---------------------------------------- | ------------------------------------------------------ |
55
+ | `id` | `TValue` | Unique identifier for the option (from `getValue`) |
56
+ | `source` | `TSource` | The original source object |
57
+ | `flags` | `{ selected: boolean, active: boolean }` | State flags for selection and keyboard navigation |
58
+ | `properties` | `object` | Computed properties for the option |
59
+ | &nbsp;⤷&nbsp;`label` | `string` | Human-readable option label (from `getLabel`) |
60
+ | &nbsp;⤷&nbsp;`matching` | `boolean` | Whether the option matches the current search term |
61
+ | &nbsp;⤷&nbsp;`disabled` | `boolean` | Whether the option is disabled |
62
+ | &nbsp;⤷&nbsp;`multiple` | `boolean` | Whether multiple selection is enabled |
63
+ | &nbsp;⤷&nbsp;`...` | `any` | Any other custom property defined in the config |
64
+ | `toggleFlag` | `(flag, forcedValue?) => void` | Method to toggle a flag (like 'selected') on this item |
65
+
66
+ ## Example: Basic usage with `VtsSelect`
67
+
68
+ ```vue
69
+ <template>
70
+ <VtsSelect :options :selected-label />
71
+ </template>
72
+
73
+ <script lang="ts" setup>
74
+ const { options, selectedLabel, selectedValues } = useFormOptions(vms, {
75
+ properties: vm => ({
76
+ label: vm.name_label,
77
+ }),
78
+ })
79
+ </script>
80
+ ```
81
+
82
+ ## Example: Searchable + multi-select + custom selected label
83
+
84
+ ```vue
85
+ <template>
86
+ <VtsSelect v-model:search="searchTerm" :options :selected-label />
87
+ </template>
88
+
89
+ <script lang="ts" setup>
90
+ const { options, searchTerm, selectedValues, selectedLabel } = useFormOptions(vms, {
91
+ properties: vm => ({ label: vm.name_label }),
92
+ multiple: true,
93
+ selectedlabel: count => (count > 3 ? `${count} VMs selected` : undefined), // Keep the default label if less than 3
94
+ })
95
+ </script>
96
+ ```
@@ -0,0 +1,2 @@
1
+ export * from './types.ts'
2
+ export * from './use-form-select.ts'
@@ -0,0 +1,75 @@
1
+ import type { CollectionConfigProperties, CollectionItem } from '@core/packages/collection'
2
+ import type { MaybeArray } from '@core/types/utility.type.ts'
3
+ import type { ComputedRef, InjectionKey, Reactive, WritableComputedRef } from 'vue'
4
+
5
+ export type FormSelectBaseConfig = {
6
+ multiple?: boolean
7
+ selectedLabel?: (count: number, labels: string[]) => string | undefined
8
+ }
9
+
10
+ export type FormSelectBaseProperties = Record<string, unknown> & {
11
+ disabled?: boolean
12
+ searchableTerm?: MaybeArray<string>
13
+ }
14
+
15
+ export type FormOptionValue = string | number
16
+
17
+ export type FormOptionProperties<TValue extends FormOptionValue> = {
18
+ id: TValue
19
+ label: string
20
+ multiple: boolean
21
+ disabled: boolean
22
+ matching: boolean
23
+ }
24
+
25
+ export type FormOption<
26
+ TSource = unknown,
27
+ TValue extends FormOptionValue = FormOptionValue,
28
+ TProperties extends CollectionConfigProperties = CollectionConfigProperties,
29
+ > = CollectionItem<TSource, 'active' | 'selected', TProperties & FormOptionProperties<TValue>>
30
+
31
+ export type UseFormSelectReturn<
32
+ TSource,
33
+ TValue extends FormOptionValue,
34
+ TProperties extends CollectionConfigProperties,
35
+ > = {
36
+ searchTerm: WritableComputedRef<string>
37
+ allOptions: ComputedRef<FormOption<TSource, TValue, TProperties>[]>
38
+ options: ComputedRef<FormOption<TSource, TValue, TProperties>[]>
39
+ selectedOptions: ComputedRef<FormOption<TSource, TValue, TProperties>[]>
40
+ selectedValues: ComputedRef<TValue[]>
41
+ selectedLabel: ComputedRef<string>
42
+ }
43
+
44
+ export type FormOptionIndex =
45
+ | number
46
+ | 'previous'
47
+ | 'next'
48
+ | 'previous-page'
49
+ | 'next-page'
50
+ | 'first'
51
+ | 'last'
52
+ | 'selected'
53
+
54
+ export enum FORM_SELECT_HANDLED_KEY {
55
+ DOWN = 'ArrowDown',
56
+ UP = 'ArrowUp',
57
+ LEFT = 'ArrowLeft',
58
+ RIGHT = 'ArrowRight',
59
+ ENTER = 'Enter',
60
+ SPACE = ' ',
61
+ ESCAPE = 'Escape',
62
+ HOME = 'Home',
63
+ END = 'End',
64
+ TAB = 'Tab',
65
+ PAGE_DOWN = 'PageDown',
66
+ PAGE_UP = 'PageUp',
67
+ }
68
+
69
+ export type FormSelectController = Reactive<{
70
+ isNavigatingWithKeyboard: boolean
71
+ closeDropdown(keepFocus: boolean): void
72
+ focusSearchOrTrigger(): void
73
+ }>
74
+
75
+ export const IK_FORM_SELECT_CONTROLLER = Symbol('IK_FORM_SELECT_CONTROLLER') as InjectionKey<FormSelectController>
@@ -0,0 +1,50 @@
1
+ import { type FormOption, IK_FORM_SELECT_CONTROLLER } from '@core/packages/form-select/types.ts'
2
+ import { unrefElement, useEventListener, whenever } from '@vueuse/core'
3
+ import { computed, inject, type MaybeRefOrGetter, ref, toValue } from 'vue'
4
+
5
+ export function useFormOptionController<TOption extends FormOption>(_option: MaybeRefOrGetter<TOption>) {
6
+ const controller = inject(IK_FORM_SELECT_CONTROLLER)
7
+
8
+ if (!controller) {
9
+ throw new Error('useFormOption needs a FormSelectController to be injected')
10
+ }
11
+
12
+ const option = computed(() => toValue(_option))
13
+
14
+ const elementRef = ref<HTMLDivElement>()
15
+
16
+ whenever(
17
+ () => option.value.flags.active,
18
+ () => {
19
+ unrefElement(elementRef)?.scrollIntoView({ block: 'nearest' })
20
+ }
21
+ )
22
+
23
+ useEventListener(elementRef, 'click', event => {
24
+ event.preventDefault()
25
+
26
+ if (option.value.properties.disabled) {
27
+ return
28
+ }
29
+
30
+ if (option.value.properties.multiple) {
31
+ option.value.toggleFlag('selected')
32
+ controller.focusSearchOrTrigger()
33
+ } else {
34
+ option.value.toggleFlag('selected', true)
35
+ controller.closeDropdown(true)
36
+ }
37
+ })
38
+
39
+ useEventListener(elementRef, 'mouseenter', () => {
40
+ if (option.value.properties.disabled || controller.isNavigatingWithKeyboard) {
41
+ return
42
+ }
43
+
44
+ option.value.flags.active = true
45
+ })
46
+
47
+ return {
48
+ elementRef,
49
+ }
50
+ }
@@ -0,0 +1,205 @@
1
+ import { type FormOption, type FormOptionIndex, IK_FORM_SELECT_CONTROLLER } from '@core/packages/form-select/types.ts'
2
+ import { useFormSelectKeyboardNavigation } from '@core/packages/form-select/use-form-select-keyboard-navigation.ts'
3
+ import { ifElse } from '@core/utils/if-else.utils'
4
+ import { type MaybeElement, useFloating } from '@floating-ui/vue'
5
+ import { clamp, onClickOutside, useEventListener, useFocusWithin, whenever } from '@vueuse/core'
6
+ import { logicOr } from '@vueuse/math'
7
+ import { computed, type MaybeRefOrGetter, provide, reactive, type Ref, ref, toValue, watch } from 'vue'
8
+
9
+ export function useFormSelectController<TOption extends FormOption>(config: {
10
+ options: MaybeRefOrGetter<TOption[]>
11
+ searchTerm: Ref<string | undefined>
12
+ }) {
13
+ const options = computed(() => toValue(config.options))
14
+
15
+ const isMultiple = computed(() => options.value[0]?.properties.multiple ?? false)
16
+
17
+ const activeOption = computed(() => options.value.find(option => option.flags.active))
18
+
19
+ const isOpen = ref(false)
20
+
21
+ /* TRIGGER */
22
+
23
+ const triggerRef = ref<HTMLElement>()
24
+
25
+ const { focused: isTriggerFocused } = useFocusWithin(triggerRef)
26
+
27
+ const isActive = logicOr(isTriggerFocused, isOpen)
28
+
29
+ useEventListener(triggerRef, 'click', () => openDropdown())
30
+
31
+ function focusTrigger() {
32
+ triggerRef.value?.focus?.()
33
+ }
34
+
35
+ /* KEYBOARD NAVIGATION */
36
+
37
+ const { isNavigatingWithKeyboard, stopKeyboardNavigation } = useFormSelectKeyboardNavigation({
38
+ isActive,
39
+ isMultiple,
40
+ isOpen,
41
+ onSelect: () => activeOption.value?.toggleFlag('selected', true),
42
+ onToggle: () => activeOption.value?.toggleFlag('selected'),
43
+ onOpen: openDropdown,
44
+ onClose: closeDropdown,
45
+ onMove: index => moveToOptionIndex(index),
46
+ })
47
+
48
+ /* DROPDOWN */
49
+
50
+ const dropdownRef = ref<HTMLElement>()
51
+
52
+ onClickOutside(dropdownRef, () => closeDropdown(true), { ignore: [triggerRef] })
53
+
54
+ useEventListener(dropdownRef, 'mousemove', () => stopKeyboardNavigation())
55
+
56
+ /* DROPDOWN PLACEMENT */
57
+
58
+ const { floatingStyles } = useFloating(triggerRef, dropdownRef, {
59
+ placement: 'bottom-start',
60
+ open: isOpen,
61
+ })
62
+
63
+ /* SEARCH */
64
+
65
+ watch(config.searchTerm, () => stopKeyboardNavigation())
66
+
67
+ const searchRef = ref<MaybeElement<HTMLElement> & { focus?: () => void }>()
68
+
69
+ function focusSearch() {
70
+ if (!searchRef.value?.focus) {
71
+ return false
72
+ }
73
+
74
+ searchRef.value.focus()
75
+
76
+ return true
77
+ }
78
+
79
+ whenever(isOpen, () => focusSearch(), { flush: 'post' })
80
+
81
+ const currentIndex = computed(() => options.value.findIndex(option => option.flags.active))
82
+
83
+ whenever(options, () => moveToOptionIndex('first'), { flush: 'post' })
84
+
85
+ ifElse(isOpen, () => moveToOptionIndex('selected'), clear, { flush: 'post' })
86
+
87
+ function openDropdown() {
88
+ if (!isOpen.value) {
89
+ isOpen.value = true
90
+ }
91
+ }
92
+
93
+ function closeDropdown(keepFocus: boolean) {
94
+ if (!isOpen.value) {
95
+ return
96
+ }
97
+
98
+ isOpen.value = false
99
+
100
+ if (keepFocus) {
101
+ focusTrigger()
102
+ }
103
+ }
104
+
105
+ function clear() {
106
+ if (config.searchTerm.value !== undefined) {
107
+ config.searchTerm.value = ''
108
+ }
109
+ }
110
+
111
+ const boundaryIndexes = computed(() => {
112
+ let firstIndex: number | undefined
113
+ let lastIndex: number | undefined
114
+
115
+ options.value.forEach((option, index) => {
116
+ if (option.properties.disabled) {
117
+ return
118
+ }
119
+
120
+ if (firstIndex === undefined) {
121
+ firstIndex = index
122
+ }
123
+
124
+ lastIndex = index
125
+ })
126
+
127
+ if (firstIndex === undefined || lastIndex === undefined) {
128
+ return undefined
129
+ }
130
+
131
+ return {
132
+ first: firstIndex,
133
+ last: lastIndex,
134
+ }
135
+ })
136
+
137
+ function parseIndex(index: FormOptionIndex): number {
138
+ switch (index) {
139
+ case 'previous':
140
+ return currentIndex.value - 1
141
+ case 'next':
142
+ return currentIndex.value + 1
143
+ case 'previous-page':
144
+ return currentIndex.value - 7 // TODO: Better handle page size.
145
+ case 'next-page':
146
+ return currentIndex.value + 7 // TODO: Better handle page size.
147
+ case 'first':
148
+ return 0
149
+ case 'last':
150
+ return options.value.length - 1
151
+ case 'selected':
152
+ return options.value.findIndex(option => option.flags.selected)
153
+ default:
154
+ return index
155
+ }
156
+ }
157
+
158
+ function moveToOptionIndex(_index: FormOptionIndex) {
159
+ if (boundaryIndexes.value === undefined) {
160
+ activeOption.value?.toggleFlag('active', false)
161
+ return
162
+ }
163
+
164
+ const index = clamp(parseIndex(_index), boundaryIndexes.value.first, boundaryIndexes.value.last)
165
+
166
+ options.value[getClosestEnabledIndex(index)]?.toggleFlag('active', true)
167
+ }
168
+
169
+ function getClosestEnabledIndex(expectedIndex: number) {
170
+ let index = expectedIndex
171
+
172
+ const direction = expectedIndex < currentIndex.value ? -1 : 1
173
+
174
+ while (options.value[index]?.properties.disabled) {
175
+ index += direction
176
+ }
177
+
178
+ return index
179
+ }
180
+
181
+ function focusSearchOrTrigger() {
182
+ if (!focusSearch()) {
183
+ focusTrigger()
184
+ }
185
+ }
186
+
187
+ provide(
188
+ IK_FORM_SELECT_CONTROLLER,
189
+ reactive({
190
+ isNavigatingWithKeyboard,
191
+ focusSearchOrTrigger,
192
+ closeDropdown,
193
+ })
194
+ )
195
+
196
+ return {
197
+ triggerRef,
198
+ dropdownRef,
199
+ searchRef,
200
+ openDropdown,
201
+ closeDropdown,
202
+ isOpen,
203
+ floatingStyles,
204
+ }
205
+ }