@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.
- package/lib/components/backdrop/VtsBackdrop.vue +1 -1
- package/lib/components/column/VtsColumn.vue +21 -0
- package/lib/components/columns/VtsColumns.vue +38 -0
- package/lib/components/copy-button/VtsCopyButton.vue +29 -0
- package/lib/components/enabled-state/VtsEnabledState.vue +23 -0
- package/lib/components/icon/VtsIcon.vue +9 -1
- package/lib/components/input-wrapper/VtsInputWrapper.vue +1 -0
- package/lib/components/layout/VtsLayoutSidebar.vue +1 -1
- package/lib/components/quick-info-column/VtsQuickInfoColumn.vue +1 -1
- package/lib/components/quick-info-row/VtsQuickInfoRow.vue +26 -7
- package/lib/components/relative-time/VtsRelativeTime.vue +18 -0
- package/lib/components/select/VtsOption.vue +24 -0
- package/lib/components/select/VtsSelect.vue +96 -0
- package/lib/components/state-hero/VtsLoadingHero.vue +45 -4
- package/lib/components/tree/VtsTreeItem.vue +11 -1
- package/lib/components/ui/alert/UiAlert.vue +105 -0
- package/lib/components/ui/circle-progress-bar/UiCircleProgressBar.vue +212 -0
- package/lib/components/ui/dropdown/UiDropdownList.vue +10 -2
- package/lib/components/ui/head-bar/UiHeadBar.vue +2 -2
- package/lib/components/ui/info/UiInfo.vue +5 -3
- package/lib/components/ui/input/UiInput.vue +126 -109
- package/lib/composables/chart-theme.composable.ts +3 -3
- package/lib/composables/relative-time.composable.ts +1 -1
- package/lib/i18n.ts +4 -0
- package/lib/locales/cs.json +65 -18
- package/lib/locales/en.json +65 -1
- package/lib/locales/es.json +60 -13
- package/lib/locales/fa.json +59 -12
- package/lib/locales/fr.json +67 -3
- package/lib/locales/it.json +145 -7
- package/lib/locales/nl.json +502 -0
- package/lib/locales/ru.json +91 -1
- package/lib/locales/sv.json +75 -19
- package/lib/packages/collection/README.md +172 -0
- package/lib/packages/collection/create-collection.ts +74 -0
- package/lib/packages/collection/create-item.ts +39 -0
- package/lib/packages/collection/guess-item-id.ts +26 -0
- package/lib/packages/collection/index.ts +2 -0
- package/lib/packages/collection/types.ts +57 -0
- package/lib/packages/collection/use-collection.ts +47 -0
- package/lib/packages/collection/use-flag-registry.ts +64 -0
- package/lib/packages/form-select/README.md +96 -0
- package/lib/packages/form-select/index.ts +2 -0
- package/lib/packages/form-select/types.ts +75 -0
- package/lib/packages/form-select/use-form-option-controller.ts +50 -0
- package/lib/packages/form-select/use-form-select-controller.ts +205 -0
- package/lib/packages/form-select/use-form-select-keyboard-navigation.ts +157 -0
- package/lib/packages/form-select/use-form-select.ts +193 -0
- package/lib/stores/sidebar.store.ts +14 -1
- 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
|
+
| ⤷ `label` | `string` | Human-readable option label (from `getLabel`) |
|
|
60
|
+
| ⤷ `matching` | `boolean` | Whether the option matches the current search term |
|
|
61
|
+
| ⤷ `disabled` | `boolean` | Whether the option is disabled |
|
|
62
|
+
| ⤷ `multiple` | `boolean` | Whether multiple selection is enabled |
|
|
63
|
+
| ⤷ `...` | `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,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
|
+
}
|