@xen-orchestra/web-core 0.15.0 → 0.17.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 (29) hide show
  1. package/lib/components/connection-status/VtsConnectionStatus.vue +3 -0
  2. package/lib/components/console/VtsClipboardConsole.vue +2 -2
  3. package/lib/components/console/VtsRemoteConsole.vue +1 -1
  4. package/lib/components/data-table/VtsDataTable.vue +13 -14
  5. package/lib/components/input-wrapper/VtsInputWrapper.vue +94 -0
  6. package/lib/components/menu/MenuList.vue +1 -0
  7. package/lib/components/state-hero/VtsLoadingHero.vue +1 -3
  8. package/lib/components/ui/character-limit/UiCharacterLimit.vue +35 -0
  9. package/lib/components/ui/dropdown/UiDropdown.vue +214 -0
  10. package/lib/components/ui/dropdown/UiDropdownList.vue +16 -0
  11. package/lib/components/ui/input/UiInput.vue +7 -8
  12. package/lib/components/ui/label/UiLabel.vue +3 -1
  13. package/lib/components/ui/progress-bar/UiProgressBar.vue +108 -0
  14. package/lib/components/ui/query-search-bar/UiQuerySearchBar.vue +1 -1
  15. package/lib/components/ui/table-pagination/UiTablePagination.vue +23 -57
  16. package/lib/components/ui/{input → text-area}/UiTextarea.vue +54 -12
  17. package/lib/composables/mapper.composable.md +74 -0
  18. package/lib/composables/mapper.composable.ts +18 -0
  19. package/lib/composables/pagination.composable.md +32 -0
  20. package/lib/composables/pagination.composable.ts +91 -0
  21. package/lib/composables/ranked.composable.md +118 -0
  22. package/lib/composables/ranked.composable.ts +37 -0
  23. package/lib/locales/en.json +23 -1
  24. package/lib/locales/fr.json +22 -0
  25. package/lib/utils/if-else.utils.ts +1 -1
  26. package/lib/utils/injection-keys.util.ts +3 -2
  27. package/package.json +22 -22
  28. package/lib/components/dropdown/DropdownItem.vue +0 -163
  29. package/lib/components/dropdown/DropdownList.vue +0 -31
@@ -2,27 +2,27 @@
2
2
  <template>
3
3
  <div class="ui-table-pagination">
4
4
  <div class="buttons-container">
5
- <PaginationButton :disabled="isFirstPage || disabled" :icon="faAngleDoubleLeft" @click="goToFirstPage()" />
6
- <PaginationButton :disabled="isFirstPage || disabled" :icon="faAngleLeft" @click="goToPreviousPage()" />
7
- <PaginationButton :disabled="isLastPage || disabled" :icon="faAngleRight" @click="goToNextPage()" />
8
- <PaginationButton :disabled="isLastPage || disabled" :icon="faAngleDoubleRight" @click="goToLastPage()" />
5
+ <PaginationButton :disabled="isFirstPage" :icon="faAngleDoubleLeft" @click="emit('first')" />
6
+ <PaginationButton :disabled="isFirstPage" :icon="faAngleLeft" @click="emit('previous')" />
7
+ <PaginationButton :disabled="isLastPage" :icon="faAngleRight" @click="emit('next')" />
8
+ <PaginationButton :disabled="isLastPage" :icon="faAngleDoubleRight" @click="emit('last')" />
9
9
  </div>
10
10
  <span class="typo-body-regular-small label">
11
- {{ $t('core.select.n-object-of', { from: startIndex, to: endIndex, total: totalItems }) }}
11
+ {{ $t('core.select.n-object-of', { from, to, total }) }}
12
12
  </span>
13
- <span class="typo-body-regular-small label show">{{ $t('core.show-by') }}</span>
13
+ <span class="typo-body-regular-small label show">{{ $t('core.pagination.show-by') }}</span>
14
14
  <div class="dropdown-wrapper">
15
- <select v-model="pageSize" :disabled class="dropdown typo-body-regular-small" @change="goToFirstPage">
16
- <option v-for="option in pageSizeOptions" :key="option" :value="option" class="typo-body-bold-small">
17
- {{ option }}
15
+ <select v-model="showBy" class="dropdown typo-body-regular-small">
16
+ <option v-for="option in [50, 100, 150, 200, -1]" :key="option" :value="option" class="typo-body-bold-small">
17
+ {{ option === -1 ? $t('core.pagination.all') : option }}
18
18
  </option>
19
19
  </select>
20
- <VtsIcon class="icon" accent="current" :icon="faAngleDown" />
20
+ <VtsIcon :icon="faAngleDown" accent="current" class="icon" />
21
21
  </div>
22
22
  </div>
23
23
  </template>
24
24
 
25
- <script setup lang="ts">
25
+ <script lang="ts" setup>
26
26
  import VtsIcon from '@core/components/icon/VtsIcon.vue'
27
27
  import PaginationButton from '@core/components/ui/table-pagination/PaginationButton.vue'
28
28
  import {
@@ -32,60 +32,26 @@ import {
32
32
  faAngleLeft,
33
33
  faAngleRight,
34
34
  } from '@fortawesome/free-solid-svg-icons'
35
- import { useOffsetPagination } from '@vueuse/core'
36
- import { computed, ref, watch } from 'vue'
37
-
38
- export type PaginationPayload = {
39
- currentPage: number
40
- pageSize: number
41
- startIndex: number
42
- endIndex: number
43
- }
44
35
 
45
- const { totalItems, disabled = false } = defineProps<{
46
- totalItems: number
47
- disabled?: boolean
36
+ defineProps<{
37
+ from: number
38
+ to: number
39
+ total: number
40
+ isFirstPage: boolean
41
+ isLastPage: boolean
48
42
  }>()
49
43
 
50
44
  const emit = defineEmits<{
51
- change: [payload: PaginationPayload]
45
+ first: []
46
+ previous: []
47
+ next: []
48
+ last: []
52
49
  }>()
53
50
 
54
- const pageSize = ref(50)
55
- const pageSizeOptions = [10, 50, 100, 150, 200]
56
- const {
57
- currentPage,
58
- currentPageSize,
59
- pageCount,
60
- isFirstPage,
61
- isLastPage,
62
- prev: goToPreviousPage,
63
- next: goToNextPage,
64
- } = useOffsetPagination({
65
- total: () => totalItems,
66
- pageSize,
67
- })
68
- const startIndex = computed(() => (currentPage.value - 1) * currentPageSize.value + 1)
69
- const endIndex = computed(() => Math.min(currentPage.value * currentPageSize.value, totalItems))
70
-
71
- const goToFirstPage = () => {
72
- currentPage.value = 1
73
- }
74
- const goToLastPage = () => {
75
- currentPage.value = pageCount.value
76
- }
77
-
78
- watch([currentPage, currentPageSize], ([newPage, newPageSize]) => {
79
- emit('change', {
80
- currentPage: newPage,
81
- pageSize: newPageSize,
82
- startIndex: startIndex.value,
83
- endIndex: endIndex.value,
84
- })
85
- })
51
+ const showBy = defineModel<number>('showBy', { default: 50 })
86
52
  </script>
87
53
 
88
- <style scoped lang="postcss">
54
+ <style lang="postcss" scoped>
89
55
  .ui-table-pagination {
90
56
  display: flex;
91
57
  align-items: center;
@@ -1,25 +1,41 @@
1
1
  <!-- v2 -->
2
2
  <template>
3
- <div class="ui-textarea" :class="toVariants({ accent })">
4
- <UiLabel v-if="slots.default" :accent="labelAccent" :required :icon :href><slot /></UiLabel>
5
- <textarea v-model="model" :disabled class="textarea" v-bind="attrs" />
6
- <UiInfo v-if="slots.info" :accent><slot name="info" /></UiInfo>
3
+ <div class="ui-textarea" :class="toVariants({ accent: hasMaxCharactersError ? 'danger' : accent })">
4
+ <UiLabel v-if="slots.default" :accent="labelAccent" :required :icon :href :for="id">
5
+ <slot />
6
+ </UiLabel>
7
+ <textarea v-bind="attrs" :id ref="textarea" v-model="model" :disabled class="textarea" />
8
+ <UiCharacterLimit v-if="maxCharacters" :count="model.trim().length" :max="maxCharacters" />
9
+ <UiInfo v-if="isExceedingMaxCharacters" accent="danger">
10
+ {{ $t('core.textarea.exceeds-max-characters', { max: maxCharacters }) }}
11
+ </UiInfo>
12
+ <UiInfo v-if="slots.info" :accent="accent === 'brand' ? 'info' : accent">
13
+ <slot name="info" />
14
+ </UiInfo>
7
15
  </div>
8
16
  </template>
9
17
 
10
18
  <script lang="ts" setup>
19
+ import UiCharacterLimit from '@core/components/ui/character-limit/UiCharacterLimit.vue'
11
20
  import UiInfo from '@core/components/ui/info/UiInfo.vue'
12
21
  import UiLabel from '@core/components/ui/label/UiLabel.vue'
13
22
  import { toVariants } from '@core/utils/to-variants.util'
14
23
  import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
15
- import { computed, useAttrs } from 'vue'
24
+ import { useFocus } from '@vueuse/core'
25
+ import { computed, useAttrs, useId, useTemplateRef } from 'vue'
16
26
 
17
27
  defineOptions({
18
28
  inheritAttrs: false,
19
29
  })
20
30
 
21
- const props = defineProps<{
22
- accent: 'info' | 'warning' | 'danger'
31
+ const {
32
+ accent,
33
+ maxCharacters,
34
+ id = useId(),
35
+ } = defineProps<{
36
+ accent: 'brand' | 'warning' | 'danger'
37
+ id?: string
38
+ maxCharacters?: number
23
39
  disabled?: boolean
24
40
  href?: string
25
41
  icon?: IconDefinition
@@ -35,7 +51,24 @@ const slots = defineSlots<{
35
51
 
36
52
  const attrs = useAttrs()
37
53
 
38
- const labelAccent = computed(() => (props.accent === 'info' ? 'neutral' : props.accent))
54
+ const textAreaElement = useTemplateRef('textarea')
55
+
56
+ const { focused } = useFocus(textAreaElement)
57
+
58
+ // WIP: To update when using VeeValidate and custom validation rules
59
+ const isExceedingMaxCharacters = computed(() =>
60
+ maxCharacters !== undefined ? model.value.trim().length > maxCharacters : false
61
+ )
62
+
63
+ const hasMaxCharactersError = computed(() => !focused.value && isExceedingMaxCharacters.value)
64
+
65
+ const labelAccent = computed(() => {
66
+ if (hasMaxCharactersError.value) {
67
+ return 'danger'
68
+ }
69
+
70
+ return accent === 'brand' ? 'neutral' : accent
71
+ })
39
72
  </script>
40
73
 
41
74
  <style lang="postcss" scoped>
@@ -54,20 +87,23 @@ const labelAccent = computed(() => (props.accent === 'info' ? 'neutral' : props.
54
87
  width: 100%;
55
88
  }
56
89
 
57
- &.accent--info {
90
+ &.accent--brand {
58
91
  .textarea {
59
92
  border-color: var(--color-neutral-border);
60
93
 
61
94
  &:hover {
62
- border-color: var(--color-info-item-hover);
95
+ border-color: var(--color-brand-item-hover);
63
96
  }
97
+
64
98
  &:active {
65
- border-color: var(--color-info-item-active);
99
+ border-color: var(--color-brand-item-active);
66
100
  }
101
+
67
102
  &:focus:not(:active) {
68
103
  border-width: 0.2rem;
69
- border-color: var(--color-info-item-base);
104
+ border-color: var(--color-brand-item-base);
70
105
  }
106
+
71
107
  &:disabled {
72
108
  background-color: var(--color-neutral-background-disabled);
73
109
  border-color: var(--color-neutral-border);
@@ -82,13 +118,16 @@ const labelAccent = computed(() => (props.accent === 'info' ? 'neutral' : props.
82
118
  &:hover {
83
119
  border-color: var(--color-warning-item-hover);
84
120
  }
121
+
85
122
  &:active {
86
123
  border-color: var(--color-warning-item-active);
87
124
  }
125
+
88
126
  &:focus:not(:active) {
89
127
  border-width: 0.2rem;
90
128
  border-color: var(--color-warning-item-base);
91
129
  }
130
+
92
131
  &:disabled {
93
132
  background-color: var(--color-neutral-background-disabled);
94
133
  border-color: var(--color-neutral-border);
@@ -103,13 +142,16 @@ const labelAccent = computed(() => (props.accent === 'info' ? 'neutral' : props.
103
142
  &:hover {
104
143
  border-color: var(--color-danger-item-hover);
105
144
  }
145
+
106
146
  &:active {
107
147
  border-color: var(--color-danger-item-active);
108
148
  }
149
+
109
150
  &:focus:not(:active) {
110
151
  border-width: 0.2rem;
111
152
  border-color: var(--color-danger-item-base);
112
153
  }
154
+
113
155
  &:disabled {
114
156
  background-color: var(--color-neutral-background-disabled);
115
157
  border-color: var(--color-neutral-border);
@@ -0,0 +1,74 @@
1
+ # `useMapper` composable
2
+
3
+ This composable maps values from one type to another using a mapping record. It takes a source value, a mapping object, and a default value to use when the source value is undefined or not found in the mapping.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ const mappedValue = useMapper(sourceValue, mapping, defaultValue)
9
+ ```
10
+
11
+ | | Required | Type | Default | |
12
+ | -------------- | :------: | ------------------------- | ------- | --------------------------------------------------------------------------------- |
13
+ | `sourceValue` | ✓ | `MaybeRefOrGetter<TFrom>` | | The source value to be mapped. Can be a ref, a getter function, or a raw value |
14
+ | `mapping` | ✓ | `Record<TFrom, TTo>` | | An object mapping source values to destination values |
15
+ | `defaultValue` | ✓ | `MaybeRefOrGetter<TTo>` | | The default value to use when the source is undefined or not found in the mapping |
16
+
17
+ ## Return value
18
+
19
+ | | Type | |
20
+ | ------------- | ------------------ | ------------------------------------------------ |
21
+ | `mappedValue` | `ComputedRef<TTo>` | A computed reference containing the mapped value |
22
+
23
+ ## Example
24
+
25
+ ```vue
26
+ <template>
27
+ <div>
28
+ <p>Selected car color: {{ carColor }}</p>
29
+ <p>Recommended wall color: {{ wallColor }}</p>
30
+
31
+ <button @click="carColor = 'red'">Red Car</button>
32
+ <button @click="carColor = 'blue'">Blue Car</button>
33
+ <button @click="carColor = 'black'">Black Car</button>
34
+ <button @click="carColor = 'green'">Green Car</button>
35
+ <button @click="carColor = 'silver'">Silver Car</button>
36
+ <button @click="carColor = undefined">No Car</button>
37
+ </div>
38
+ </template>
39
+
40
+ <script lang="ts" setup>
41
+ import { useMapper } from '@/composables/mapper'
42
+ import { ref } from 'vue'
43
+
44
+ // Source type
45
+ type CarColor = 'red' | 'blue' | 'black' | 'green' | 'silver'
46
+
47
+ // Destination type
48
+ type WallColor = 'beige' | 'lightGray' | 'cream' | 'white'
49
+
50
+ // Create a ref for the source value
51
+ const carColor = ref<CarColor | undefined>(undefined)
52
+
53
+ // Create a computed property that maps car color to wall color
54
+ const wallColor = useMapper<CarColor, WallColor>(
55
+ carColor,
56
+ {
57
+ red: 'beige',
58
+ blue: 'lightGray',
59
+ black: 'cream',
60
+ green: 'beige',
61
+ silver: 'lightGray',
62
+ },
63
+ 'white'
64
+ )
65
+ </script>
66
+ ```
67
+
68
+ In this example:
69
+
70
+ - When `carColor.value` is `'red'` or `'green'`, `wallColor.value` will be `'beige'`
71
+ - When `carColor.value` is `'blue'` or `'silver'`, `wallColor.value` will be `'lightGray'`
72
+ - When `carColor.value` is `'black'`, `wallColor.value` will be `'cream'`
73
+ - When `carColor.value` is `undefined` or any value not in the mapping, `wallColor.value` will be
74
+ `'white'` (the default value)
@@ -0,0 +1,18 @@
1
+ import { computed, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
2
+
3
+ export function useMapper<TFrom extends string | number, TTo>(
4
+ _source: MaybeRefOrGetter<TFrom | undefined>,
5
+ mapping: Record<TFrom, TTo>,
6
+ _defaultValue: MaybeRefOrGetter<TTo>
7
+ ): ComputedRef<TTo> {
8
+ return computed(() => {
9
+ const source = toValue(_source)
10
+ const defaultValue = toValue(_defaultValue)
11
+
12
+ if (source === undefined) {
13
+ return defaultValue
14
+ }
15
+
16
+ return Object.prototype.hasOwnProperty.call(mapping, source) ? mapping[source] : defaultValue
17
+ })
18
+ }
@@ -0,0 +1,32 @@
1
+ # usePagination Composable
2
+
3
+ Handles record pagination with localStorage and route persistence.
4
+
5
+ The composable uses index-based pagination instead of page numbers, enabling URL sharing across users with different "Show by" settings. The index represents the first visible record.
6
+
7
+ ## Storage
8
+
9
+ - Route query: `{id}.idx` stores start index
10
+ - LocalStorage: `{id}.per-page` stores "Show by" value (default: 50)
11
+
12
+ ## Key Points
13
+
14
+ - `showBy = -1` displays all records
15
+ - `pageRecords` returns current page's records subset
16
+ - `seek(predicate)` finds and navigates to specific record's page
17
+ - All indices auto-align to page boundaries
18
+ - `paginationBindings` contains props and events for `UiTablePagination`
19
+
20
+ ## Usage
21
+
22
+ ```typescript
23
+ // Basic usage
24
+ const { pageRecords, paginationBindings } = usePagination('items', items)
25
+
26
+ // Template
27
+ <div v-for="item in pageRecords" :key="item.id">
28
+ {{ item.name }}
29
+ </div>
30
+
31
+ <UiTablePagination v-bind="paginationBindings" />
32
+ ```
@@ -0,0 +1,91 @@
1
+ import { useRouteQuery } from '@core/composables/route-query.composable'
2
+ import { clamp, useLocalStorage } from '@vueuse/core'
3
+ import { computed, type MaybeRefOrGetter, toValue } from 'vue'
4
+
5
+ export function usePagination<T>(id: string, _records: MaybeRefOrGetter<T[]>) {
6
+ const records = computed(() => toValue(_records))
7
+
8
+ const showBy = useLocalStorage(`${id}.per-page`, 50)
9
+
10
+ const pageSize = computed({
11
+ get: () => (showBy.value === -1 ? Number.MAX_SAFE_INTEGER : showBy.value),
12
+ set: value => (showBy.value = value),
13
+ })
14
+
15
+ function toStartIndex(rawIndex: number | string) {
16
+ const index = clamp(+rawIndex, 0, records.value.length - 1) || 0
17
+
18
+ return Math.floor(index / pageSize.value) * pageSize.value
19
+ }
20
+
21
+ const startIndex = useRouteQuery<number>(`${id}.idx`, {
22
+ defaultQuery: '0',
23
+ toData: value => toStartIndex(value),
24
+ toQuery: value => toStartIndex(value).toString(10),
25
+ })
26
+
27
+ const endIndex = computed(() => Math.min(startIndex.value + pageSize.value - 1, records.value.length - 1))
28
+
29
+ const isFirstPage = computed(() => startIndex.value <= 0)
30
+
31
+ const isLastPage = computed(() => endIndex.value >= records.value.length - 1)
32
+
33
+ const pageRecords = computed(() => records.value.slice(startIndex.value, endIndex.value + 1))
34
+
35
+ function seek(predicate: (record: T) => boolean) {
36
+ const index = records.value.findIndex(predicate)
37
+
38
+ if (index !== -1) {
39
+ startIndex.value = index
40
+ }
41
+ }
42
+
43
+ function goToNextPage() {
44
+ if (!isLastPage.value) {
45
+ startIndex.value = startIndex.value + pageSize.value
46
+ }
47
+ }
48
+
49
+ function goToPreviousPage() {
50
+ if (!isFirstPage.value) {
51
+ startIndex.value = startIndex.value - pageSize.value
52
+ }
53
+ }
54
+
55
+ function goToFirstPage() {
56
+ startIndex.value = 0
57
+ }
58
+
59
+ function goToLastPage() {
60
+ startIndex.value = records.value.length - 1
61
+ }
62
+
63
+ const paginationBindings = computed(() => ({
64
+ showBy: showBy.value,
65
+ 'onUpdate:showBy': (value: number) => (showBy.value = value),
66
+ from: Math.max(0, startIndex.value + 1),
67
+ to: endIndex.value + 1,
68
+ total: records.value.length,
69
+ isFirstPage: isFirstPage.value,
70
+ isLastPage: isLastPage.value,
71
+ onFirst: goToFirstPage,
72
+ onLast: goToLastPage,
73
+ onNext: goToNextPage,
74
+ onPrevious: goToPreviousPage,
75
+ }))
76
+
77
+ return {
78
+ startIndex,
79
+ endIndex,
80
+ seek,
81
+ showBy,
82
+ isFirstPage,
83
+ isLastPage,
84
+ goToPreviousPage,
85
+ goToNextPage,
86
+ goToFirstPage,
87
+ goToLastPage,
88
+ pageRecords,
89
+ paginationBindings,
90
+ }
91
+ }
@@ -0,0 +1,118 @@
1
+ # `useRanked` composable
2
+
3
+ This composable helps sort items based on a predefined ranking order.
4
+
5
+ It takes an array of items and a ranking order, and returns a computed sorted array according to the ranking.
6
+
7
+ ## Usage
8
+
9
+ ### Signature 1: Sorting ranks directly
10
+
11
+ ```ts
12
+ const sortedRanks = useRanked(ranks, ranking)
13
+ ```
14
+
15
+ | | Required | Type | Default | Description |
16
+ | --------- | :------: | --------------------------- | ------- | ---------------------------------------------------------- |
17
+ | `ranks` | ✓ | `MaybeRefOrGetter<TRank[]>` | | The array of ranks to sort |
18
+ | `ranking` | ✓ | `TRank[]` | | The array of ranks ordered from highest to lowest priority |
19
+
20
+ #### Return value
21
+
22
+ | | Type | Description |
23
+ | ------------- | ---------------------- | --------------------------------------------------------------- |
24
+ | `sortedRanks` | `ComputedRef<TRank[]>` | A computed array of ranks sorted according to the ranking order |
25
+
26
+ - Ranks are sorted based on their position in the `ranking` array (lower index = higher priority)
27
+ - Ranks not found in the `ranking` array are placed after all defined ranks in their original order
28
+
29
+ #### Example: Sorting priority levels
30
+
31
+ ```ts
32
+ import { useRanked } from '@core/composables/ranked.composable'
33
+ import { ref } from 'vue'
34
+
35
+ // Priority levels in random order
36
+ const currentPriorities = ref(['high', 'medium', 'low', 'critical', 'low', 'medium', 'high'])
37
+
38
+ // Define the ranking order (from highest to lowest priority)
39
+ const priorityRanking = ['critical', 'high', 'medium', 'low']
40
+
41
+ // Sort priorities according to the ranking
42
+ const sortedPriorities = useRanked(currentPriorities, priorityRanking)
43
+
44
+ // sortedPriorities.value will be:
45
+ // ['critical', 'high', 'high', 'medium', 'medium', 'low', 'low']
46
+ ```
47
+
48
+ ### Signature 2: Sorting items by their rank
49
+
50
+ ```ts
51
+ const sortedItems = useRanked(items, getRank, ranking)
52
+ ```
53
+
54
+ | | Required | Type | Default | Description |
55
+ | --------- | :------: | --------------------------- | ------- | ---------------------------------------------------------- |
56
+ | `items` | ✓ | `MaybeRefOrGetter<TItem[]>` | | The array of items to sort |
57
+ | `getRank` | ✓ | `(item: TItem) => TRank` | | A function to extract rank from an item |
58
+ | `ranking` | ✓ | `TRank[]` | | The array of ranks ordered from highest to lowest priority |
59
+
60
+ #### Return value
61
+
62
+ | | Type | Description |
63
+ | ------------- | ---------------------- | ------------------------------------------------- |
64
+ | `sortedItems` | `ComputedRef<TItem[]>` | A computed array of items sorted by their ranking |
65
+
66
+ - Items are sorted based on their rank in the `ranking` array (lower index = higher priority)
67
+ - Items with ranks not found in the `ranking` array are placed after all ranked items in their original order
68
+
69
+ #### Example: Sorting tasks by priority
70
+
71
+ ```ts
72
+ import { useRanked } from '@core/composables/ranked.composable'
73
+ import { ref } from 'vue'
74
+
75
+ // Tasks with different priorities
76
+ const tasks = ref([
77
+ {
78
+ id: 1,
79
+ title: 'Fix login bug',
80
+ priority: 'high',
81
+ },
82
+ {
83
+ id: 2,
84
+ title: 'Update documentation',
85
+ priority: 'low',
86
+ },
87
+ {
88
+ id: 3,
89
+ title: 'Server outage',
90
+ priority: 'critical',
91
+ },
92
+ {
93
+ id: 4,
94
+ title: 'Refactor components',
95
+ priority: 'medium',
96
+ },
97
+ {
98
+ id: 5,
99
+ title: 'Design review',
100
+ priority: 'medium',
101
+ },
102
+ ])
103
+
104
+ // Define the ranking order (from highest to lowest priority)
105
+ const priorityRanking = ['critical', 'high', 'medium', 'low']
106
+
107
+ // Sort tasks by priority according to the ranking
108
+ const sortedTasks = useRanked(tasks, task => task.priority, priorityRanking)
109
+
110
+ // sortedTasks.value will be:
111
+ // [
112
+ // { id: 3, title: 'Server outage', priority: 'critical' },
113
+ // { id: 1, title: 'Fix login bug', priority: 'high' },
114
+ // { id: 4, title: 'Refactor components', priority: 'medium' },
115
+ // { id: 5, title: 'Design review', priority: 'medium' },
116
+ // { id: 2, title: 'Update documentation', priority: 'low' }
117
+ // ]
118
+ ```
@@ -0,0 +1,37 @@
1
+ import { clamp, useSorted } from '@vueuse/core'
2
+ import { computed, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
3
+
4
+ export function useRanked<TRank extends string | number>(
5
+ ranks: MaybeRefOrGetter<TRank[]>,
6
+ ranking: NoInfer<TRank>[]
7
+ ): ComputedRef<TRank[]>
8
+
9
+ export function useRanked<TItem, TRank extends string | number>(
10
+ items: MaybeRefOrGetter<TItem[]>,
11
+ getRank: (item: TItem) => TRank,
12
+ ranking: NoInfer<TRank>[]
13
+ ): ComputedRef<TItem[]>
14
+
15
+ export function useRanked<TItem, TRank extends string | number>(
16
+ items: MaybeRefOrGetter<TItem[]>,
17
+ ranksOrGetRank: TRank[] | ((item: TItem) => TRank),
18
+ ranksOrNone?: TRank[]
19
+ ) {
20
+ const getRank = typeof ranksOrGetRank === 'function' ? ranksOrGetRank : (item: TItem) => item as unknown as TRank
21
+
22
+ const ranks = ranksOrNone === undefined ? (ranksOrGetRank as TRank[]) : ranksOrNone
23
+
24
+ const ranksMap = computed(() => Object.fromEntries(ranks.map((rank, index) => [rank, index + 1]))) as ComputedRef<
25
+ Record<TRank, number>
26
+ >
27
+
28
+ function getRankNumber(item: TItem): number {
29
+ return ranksMap.value[getRank(item)] ?? toValue(items).length + 1
30
+ }
31
+
32
+ function compare(item1: TItem, item2: TItem) {
33
+ return clamp(getRankNumber(item1) - getRankNumber(item2), -1, 1) as -1 | 0 | 1
34
+ }
35
+
36
+ return useSorted(items, compare)
37
+ }