@saooti/octopus-sdk 41.12.0-beta2 → 41.12.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/.claude/scheduled_tasks.lock +1 -0
- package/CHANGELOG.md +7 -1
- package/index.ts +1 -1
- package/package.json +3 -3
- package/src/api/index.ts +1 -0
- package/src/api/radioApi.ts +18 -2
- package/src/api/rubriquesApi.ts +68 -0
- package/src/components/composable/form/useOctopusDropdown.ts +5 -2
- package/src/components/composable/usePresentationItem.ts +78 -0
- package/src/components/display/emission/EmissionInlineList.vue +1 -1
- package/src/components/display/emission/EmissionPresentationItem.vue +25 -16
- package/src/components/display/podcastmaker/PodcastmakerHeader.vue +60 -45
- package/src/components/display/podcasts/PodcastPresentationList.vue +103 -89
- package/src/components/form/ClassicButtonGroup.vue +12 -6
- package/src/components/form/OctopusMultiselect.vue +86 -9
- package/src/components/form/OctopusSelect.vue +29 -2
- package/src/components/layouts/PresentationItem.vue +149 -104
- package/src/locale/de.json +1 -0
- package/src/locale/en.json +1 -0
- package/src/locale/es.json +1 -0
- package/src/locale/fr.json +1 -0
- package/src/locale/it.json +1 -0
- package/src/locale/sl.json +1 -0
- package/src/stores/CacheStore.ts +17 -11
- package/src/stores/ParamSdkStore.ts +22 -0
- package/src/stores/class/general/organisation.ts +10 -0
- package/src/stores/class/radio/canal.ts +30 -8
- package/src/stores/class/radio/recurrence.ts +6 -5
- package/src/style/_variables.scss +2 -0
- package/src/style/bootstrap.scss +3 -2
- package/tests/components/composable/usePresentationItem.spec.ts +163 -0
- package/tests/components/display/emission/EmissionPresentationItem.spec.ts +45 -0
- package/tests/components/display/podcasts/PodcastPresentationList.spec.ts +107 -0
- package/tests/components/form/ClassicButtonGroup.spec.ts +26 -0
- package/tests/components/form/OctopusMultiselect.spec.ts +298 -0
- package/tests/components/form/OctopusSelect.spec.ts +71 -0
- package/tests/components/layouts/PresentationItem.spec.ts +36 -0
- package/tests/stores/CacheStore.spec.ts +109 -0
|
@@ -2,37 +2,39 @@
|
|
|
2
2
|
Simple component to display a few podcasts
|
|
3
3
|
-->
|
|
4
4
|
<template>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
5
|
+
<PresentationLayout
|
|
6
|
+
v-if="!loading && !error"
|
|
7
|
+
:title="title"
|
|
8
|
+
:items="podcasts"
|
|
9
|
+
:route="href"
|
|
10
|
+
:button-text="buttonText"
|
|
11
|
+
>
|
|
12
|
+
<template #item="{ item, first }">
|
|
13
|
+
<PresentationItem
|
|
14
|
+
:class="!isPhone && first ? 'me-3' : ''"
|
|
15
|
+
:name="item.title"
|
|
16
|
+
:route="route(item)"
|
|
17
|
+
:image-url="item.imageUrl"
|
|
18
|
+
:description="item.description"
|
|
19
|
+
:vertical="!isPhone && first"
|
|
20
|
+
:tags="tags.get(item.podcastId)"
|
|
21
|
+
:additional-info="additionalInfoFor(item)"
|
|
22
|
+
>
|
|
23
|
+
<template #after-image>
|
|
24
|
+
<PodcastPlayButton
|
|
25
|
+
:podcast="item"
|
|
26
|
+
:hide-play="false"
|
|
27
|
+
:show-processing="false"
|
|
28
|
+
/>
|
|
29
|
+
</template>
|
|
30
|
+
</PresentationItem>
|
|
27
31
|
</template>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
:error-text="error ? $t(`Error`) : undefined"
|
|
35
|
-
/>
|
|
32
|
+
</PresentationLayout>
|
|
33
|
+
<ClassicLoading
|
|
34
|
+
v-else
|
|
35
|
+
:loading-text="loading ? $t('Loading emissions ...') : undefined"
|
|
36
|
+
:error-text="error ? $t(`Error`) : undefined"
|
|
37
|
+
/>
|
|
36
38
|
</template>
|
|
37
39
|
|
|
38
40
|
<script setup lang="ts">
|
|
@@ -40,7 +42,7 @@ import classicApi from "../../../api/classicApi";
|
|
|
40
42
|
import {useErrorHandler} from "../../composable/useErrorHandler";
|
|
41
43
|
import ClassicLoading from "../../form/ClassicLoading.vue";
|
|
42
44
|
import { Emission } from "@/stores/class/general/emission";
|
|
43
|
-
import { onMounted, Ref, ref } from "vue";
|
|
45
|
+
import { onMounted, reactive, Ref, ref, watch } from "vue";
|
|
44
46
|
import { AxiosError } from "axios";
|
|
45
47
|
import {useResizePhone} from "../../composable/useResizePhone";
|
|
46
48
|
import { ListClassicReturn } from "../../../stores/class/general/listReturn";
|
|
@@ -52,89 +54,101 @@ import PresentationItem from "../../layouts/PresentationItem.vue";
|
|
|
52
54
|
import PodcastPlayButton from "./PodcastPlayButton.vue";
|
|
53
55
|
import { RouteLocationRaw } from "vue-router";
|
|
54
56
|
import { podcastApi, PodcastSort } from "../../../api/podcastApi";
|
|
57
|
+
import { usePresentationItem } from "../../composable/usePresentationItem";
|
|
55
58
|
|
|
56
59
|
//Props
|
|
57
60
|
const props = defineProps({
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
organisationId: { default: undefined, type: String },
|
|
62
|
+
title: { default: "", type: String },
|
|
63
|
+
href: { default: undefined, type: String },
|
|
64
|
+
buttonText: { default: undefined, type: String },
|
|
65
|
+
isDescription: { default: false, type: Boolean },
|
|
66
|
+
rubriquesId: { default: [], type: Array<number> },
|
|
64
67
|
})
|
|
65
68
|
|
|
66
69
|
//Data
|
|
67
70
|
const loading = ref(true);
|
|
68
71
|
const error = ref(false);
|
|
69
72
|
const podcasts: Ref<Array<Podcast>> = ref([]);
|
|
73
|
+
const tags = reactive(new Map<number, Array<string>>());
|
|
70
74
|
|
|
71
75
|
//Composables
|
|
72
76
|
const { isPhone } = useResizePhone();
|
|
73
|
-
const {handle403} = useErrorHandler();
|
|
77
|
+
const { handle403 } = useErrorHandler();
|
|
78
|
+
const { tagsFor, additionalInfoFor } = usePresentationItem();
|
|
74
79
|
|
|
75
|
-
onMounted(
|
|
80
|
+
onMounted(fetchNext);
|
|
81
|
+
|
|
82
|
+
watch(podcasts, async () => {
|
|
83
|
+
podcasts.value.forEach(async (podcast) => {
|
|
84
|
+
if (!tags.has(podcast.podcastId)) {
|
|
85
|
+
const t = await tagsFor(podcast);
|
|
86
|
+
tags.set(podcast.podcastId, t);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
76
90
|
|
|
77
91
|
//Methods
|
|
78
92
|
async function fetchNext(): Promise<void> {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
loading.value = true;
|
|
94
|
+
try {
|
|
95
|
+
// Retrieve latest emissions
|
|
96
|
+
const emissions = await classicApi.fetchData<ListClassicReturn<Emission>>({
|
|
97
|
+
api: 0,
|
|
98
|
+
path: "emission/search",
|
|
99
|
+
parameters: {
|
|
100
|
+
first: 0,
|
|
101
|
+
size: 5,
|
|
102
|
+
organisationId: props.organisationId,
|
|
103
|
+
sort: "LAST_PODCAST_DESC",
|
|
104
|
+
rubriqueId: props.rubriquesId
|
|
105
|
+
},
|
|
106
|
+
specialTreatement: true,
|
|
107
|
+
});
|
|
94
108
|
|
|
95
|
-
|
|
109
|
+
const promises: Array<Promise<SimplifiedPodcast>> = [];
|
|
96
110
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
for (let i = 0; i < emissions.result.length; i++) {
|
|
112
|
+
promises.push(podcastApi.search({
|
|
113
|
+
first: 0,
|
|
114
|
+
size: 1,
|
|
115
|
+
organisationId: [props.organisationId],
|
|
116
|
+
emissionId: [emissions.result[i].emissionId],
|
|
117
|
+
sort: PodcastSort.DATE,
|
|
118
|
+
rubriqueId: props.rubriquesId
|
|
119
|
+
}).then(r => r.result[0]));
|
|
120
|
+
}
|
|
107
121
|
|
|
108
|
-
|
|
109
|
-
|
|
122
|
+
// Retrieve the podcasts for these emissions
|
|
123
|
+
const data = await Promise.all(promises);
|
|
110
124
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
podcasts.value = podcasts.value.concat(
|
|
126
|
+
data.filter((em: SimplifiedPodcast | null) => null !== em && undefined !== em).map(p => {
|
|
127
|
+
// Get emission from podcast
|
|
128
|
+
const emission = emissions.result.find(e => e.emissionId === p.emissionId);
|
|
129
|
+
// Create full podcast from simplified + emission
|
|
130
|
+
return simplifiedToFull(p, emission.orga, emission);
|
|
131
|
+
})
|
|
132
|
+
);
|
|
119
133
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
// Sort podcasts by pub date so that the most recent one is focused
|
|
135
|
+
podcasts.value.sort((p1, p2) => {
|
|
136
|
+
return new Date(p2.pubDate).getTime() - new Date(p1.pubDate).getTime();
|
|
137
|
+
});
|
|
124
138
|
|
|
139
|
+
loading.value = false;
|
|
140
|
+
} catch (errorWs) {
|
|
141
|
+
console.error(errorWs);
|
|
142
|
+
handle403(errorWs as AxiosError);
|
|
143
|
+
error.value = true;
|
|
144
|
+
}
|
|
125
145
|
loading.value = false;
|
|
126
|
-
} catch (errorWs) {
|
|
127
|
-
console.error(errorWs);
|
|
128
|
-
handle403(errorWs as AxiosError);
|
|
129
|
-
error.value = true;
|
|
130
|
-
}
|
|
131
|
-
loading.value = false;
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
function route(podcast: Podcast): RouteLocationRaw {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
149
|
+
return {
|
|
150
|
+
name: 'podcast',
|
|
151
|
+
params: { podcastId: podcast.podcastId }
|
|
152
|
+
}
|
|
139
153
|
}
|
|
140
154
|
</script>
|
|
@@ -24,6 +24,8 @@ const props = defineProps<{
|
|
|
24
24
|
value: T[];
|
|
25
25
|
/** Possible values */
|
|
26
26
|
options: ButtonGroupOption<T>[];
|
|
27
|
+
/** Select only one option */
|
|
28
|
+
solo?: boolean;
|
|
27
29
|
}>();
|
|
28
30
|
|
|
29
31
|
const emit = defineEmits<{
|
|
@@ -31,12 +33,16 @@ const emit = defineEmits<{
|
|
|
31
33
|
}>();
|
|
32
34
|
|
|
33
35
|
function toggle(value: T): void {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
if (props.solo === true) {
|
|
37
|
+
emit('update:value', [value]);
|
|
38
|
+
} else {
|
|
39
|
+
const isSelected = props.value.includes(value);
|
|
40
|
+
if (isSelected && props.value.length <= 1) { return; }
|
|
41
|
+
const newValues = isSelected
|
|
42
|
+
? props.value.filter(v => v !== value)
|
|
43
|
+
: [...props.value, value];
|
|
44
|
+
emit('update:value', newValues);
|
|
45
|
+
}
|
|
40
46
|
}
|
|
41
47
|
</script>
|
|
42
48
|
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
:disabled="isDisabled"
|
|
42
42
|
@focus="openDropdown"
|
|
43
43
|
@input="handleInput"
|
|
44
|
+
@keydown.enter="handleCustomValueEnter"
|
|
44
45
|
>
|
|
45
46
|
<button
|
|
46
47
|
class="btn-transparent octopus-multiselect-chevron"
|
|
@@ -74,14 +75,20 @@
|
|
|
74
75
|
|
|
75
76
|
<div class="octopus-multiselect-options">
|
|
76
77
|
<ClassicCheckbox
|
|
77
|
-
v-for="(option, index) in
|
|
78
|
+
v-for="(option, index) in visibleOptions"
|
|
78
79
|
:key="index"
|
|
79
80
|
:text-init="isSelected(option)"
|
|
80
81
|
:label="getLabel(option)"
|
|
81
82
|
:is-disabled="isDisabled"
|
|
82
83
|
@update:text-init="toggleOption(option)"
|
|
83
84
|
/>
|
|
84
|
-
<
|
|
85
|
+
<template v-if="allowCustomValue && searchQuery.trim()">
|
|
86
|
+
<hr v-if="visibleOptions.length > 0">
|
|
87
|
+
<span class="text-indic px-2">
|
|
88
|
+
{{ t('Press Enter to add this value') }}
|
|
89
|
+
</span>
|
|
90
|
+
</template>
|
|
91
|
+
<span v-else-if="visibleOptions.length === 0" class="text-indic px-2">
|
|
85
92
|
{{ t('No elements found. Consider changing the search query.') }}
|
|
86
93
|
</span>
|
|
87
94
|
</div>
|
|
@@ -106,8 +113,16 @@ const props = defineProps<{
|
|
|
106
113
|
options: T[];
|
|
107
114
|
/** Key of each option object to use as the ID */
|
|
108
115
|
optionKey?: keyof T;
|
|
109
|
-
/** Key of each option object to use as the display label.
|
|
110
|
-
|
|
116
|
+
/** Key of each option object to use as the display label. Omit when `options` is a
|
|
117
|
+
* plain `string[]` — each string is used as its own label. Note: omitting this for
|
|
118
|
+
* an object array is a runtime mistake (not caught at compile time) and will render
|
|
119
|
+
* "[object Object]". */
|
|
120
|
+
optionLabel?: keyof T & string;
|
|
121
|
+
/** When true, pressing Enter in the search input adds the typed text as a new
|
|
122
|
+
* selected value, even if it doesn't match any option. Intended for use when
|
|
123
|
+
* options are plain strings (`optionLabel` omitted) — casting arbitrary typed
|
|
124
|
+
* text into an object-shaped T would not produce a valid option. */
|
|
125
|
+
allowCustomValue?: boolean;
|
|
111
126
|
/** Disables the field and all checkboxes when true. */
|
|
112
127
|
isDisabled?: boolean;
|
|
113
128
|
/** Placeholder shown in the input when no items are selected. Defaults to the translated "Search" string. */
|
|
@@ -118,6 +133,10 @@ const props = defineProps<{
|
|
|
118
133
|
noBorder?: boolean;
|
|
119
134
|
/** When true, hovering the closed field with overflow shows a tooltip listing all selected items. */
|
|
120
135
|
expandOnHover?: boolean;
|
|
136
|
+
/** When true, options selected at the moment the dropdown opens are moved to the
|
|
137
|
+
* top of the list. This is a snapshot taken at open time — it does not live-reorder
|
|
138
|
+
* while the dropdown stays open, only on the next closed→open transition. */
|
|
139
|
+
pullSelectedToTop?: boolean;
|
|
121
140
|
}>();
|
|
122
141
|
|
|
123
142
|
const emit = defineEmits<{
|
|
@@ -166,11 +185,35 @@ function updateDropdownPosition(): void {
|
|
|
166
185
|
}
|
|
167
186
|
const visibleCount = ref(2);
|
|
168
187
|
|
|
188
|
+
// Selection snapshot captured the instant the dropdown opens, used only to freeze the
|
|
189
|
+
// sort order when pullSelectedToTop is set — does not react to later `selected` changes
|
|
190
|
+
// while the dropdown stays open.
|
|
191
|
+
const pinnedSnapshot = ref<T[]>([]);
|
|
192
|
+
|
|
193
|
+
// Selected values not present in `options` — only populated when allowCustomValue is set,
|
|
194
|
+
// so a custom value typed via handleCustomValueEnter still shows (checked) in the dropdown.
|
|
195
|
+
const customSelectedOptions = computed<T[]>(() => {
|
|
196
|
+
if (!props.allowCustomValue) { return []; }
|
|
197
|
+
return (props.selected ?? []).filter((item) => !isInOptions(item));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const visibleOptions = computed<T[]>(() => {
|
|
201
|
+
const query = searchQuery.value.toLowerCase();
|
|
202
|
+
const filteredCustom = query
|
|
203
|
+
? customSelectedOptions.value.filter((option) => getLabel(option).toLowerCase().includes(query))
|
|
204
|
+
: customSelectedOptions.value;
|
|
205
|
+
const combined = [...displayedOptions.value, ...filteredCustom];
|
|
206
|
+
if (!props.pullSelectedToTop) {
|
|
207
|
+
return combined;
|
|
208
|
+
}
|
|
209
|
+
return [...combined.filter(isPinned), ...combined.filter((option) => !isPinned(option))];
|
|
210
|
+
});
|
|
211
|
+
|
|
169
212
|
const allSelected = computed(() => {
|
|
170
|
-
if (
|
|
213
|
+
if (visibleOptions.value.length === 0) {
|
|
171
214
|
return false;
|
|
172
215
|
}
|
|
173
|
-
return
|
|
216
|
+
return visibleOptions.value.every((option) => isSelected(option));
|
|
174
217
|
});
|
|
175
218
|
|
|
176
219
|
const hasSelected = computed(() => (props.selected?.length ?? 0) > 0);
|
|
@@ -195,6 +238,22 @@ function isSelected(option: T): boolean {
|
|
|
195
238
|
}
|
|
196
239
|
}
|
|
197
240
|
|
|
241
|
+
function isInOptions(option: T): boolean {
|
|
242
|
+
if (props.optionKey) {
|
|
243
|
+
return props.options.some((opt) => opt[props.optionKey] === option[props.optionKey]);
|
|
244
|
+
} else {
|
|
245
|
+
return props.options.includes(option);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isPinned(option: T): boolean {
|
|
250
|
+
if (props.optionKey) {
|
|
251
|
+
return pinnedSnapshot.value.some((s) => s[props.optionKey] === option[props.optionKey]);
|
|
252
|
+
} else {
|
|
253
|
+
return pinnedSnapshot.value.includes(option);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
198
257
|
function toggleOption(option: T): void {
|
|
199
258
|
const current = props.selected ?? [];
|
|
200
259
|
if (isSelected(option)) {
|
|
@@ -208,16 +267,27 @@ function toggleOption(option: T): void {
|
|
|
208
267
|
}
|
|
209
268
|
}
|
|
210
269
|
|
|
270
|
+
function handleCustomValueEnter(): void {
|
|
271
|
+
if (!props.allowCustomValue) { return; }
|
|
272
|
+
const value = searchQuery.value.trim();
|
|
273
|
+
if (!value) { return; }
|
|
274
|
+
const customOption = value as unknown as T;
|
|
275
|
+
if (!isSelected(customOption)) {
|
|
276
|
+
emit('update:selected', [...(props.selected ?? []), customOption]);
|
|
277
|
+
}
|
|
278
|
+
searchQuery.value = '';
|
|
279
|
+
}
|
|
280
|
+
|
|
211
281
|
function toggleAll(val: boolean): void {
|
|
212
282
|
const current = props.selected ?? [];
|
|
213
283
|
if (val) {
|
|
214
|
-
const toAdd =
|
|
284
|
+
const toAdd = visibleOptions.value.filter((option: T) => !isSelected(option));
|
|
215
285
|
emit('update:selected', [...current, ...toAdd]);
|
|
216
286
|
} else {
|
|
217
287
|
const key = props.optionKey;
|
|
218
288
|
emit('update:selected', current.filter((item: T) => key
|
|
219
|
-
? !
|
|
220
|
-
: !
|
|
289
|
+
? !visibleOptions.value.some((opt) => opt[key] === item[key])
|
|
290
|
+
: !visibleOptions.value.includes(item)
|
|
221
291
|
));
|
|
222
292
|
}
|
|
223
293
|
}
|
|
@@ -287,6 +357,7 @@ watch(() => props.selected, updateVisibleCount);
|
|
|
287
357
|
|
|
288
358
|
watch(isOpen, (val) => {
|
|
289
359
|
if (val) {
|
|
360
|
+
pinnedSnapshot.value = [...(props.selected ?? [])];
|
|
290
361
|
nextTick(updateDropdownPosition);
|
|
291
362
|
} else {
|
|
292
363
|
nextTick(updateVisibleCount);
|
|
@@ -403,4 +474,10 @@ watch(isOpen, (val) => {
|
|
|
403
474
|
padding: 0.25rem 0.5rem;
|
|
404
475
|
}
|
|
405
476
|
}
|
|
477
|
+
|
|
478
|
+
hr {
|
|
479
|
+
border-top: 1px solid var(--octopus-secondary);
|
|
480
|
+
border-bottom: none;
|
|
481
|
+
margin: 0;
|
|
482
|
+
}
|
|
406
483
|
</style>
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
>
|
|
52
52
|
<div class="octopus-select-options">
|
|
53
53
|
<button
|
|
54
|
-
v-for="(option, index) in
|
|
54
|
+
v-for="(option, index) in visibleOptions"
|
|
55
55
|
:key="index"
|
|
56
56
|
class="octopus-select-option"
|
|
57
57
|
:class="{ selected: isSelected(option) }"
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
>
|
|
60
60
|
{{ getLabel(option) }}
|
|
61
61
|
</button>
|
|
62
|
-
<span v-if="
|
|
62
|
+
<span v-if="visibleOptions.length === 0" class="text-indic px-2">
|
|
63
63
|
{{ t('No elements found. Consider changing the search query.') }}
|
|
64
64
|
</span>
|
|
65
65
|
</div>
|
|
@@ -93,6 +93,10 @@ const props = withDefaults(defineProps<{
|
|
|
93
93
|
noBorder?: boolean;
|
|
94
94
|
/** When true (default), clicking the already-selected option clears the selection. */
|
|
95
95
|
allowDeselect?: boolean;
|
|
96
|
+
/** When true, the option matching the current value is moved to the top of the list
|
|
97
|
+
* the moment the dropdown opens. This is a snapshot taken at open time — it does not
|
|
98
|
+
* live-reorder while the dropdown stays open, only on the next closed→open transition. */
|
|
99
|
+
pullSelectedToTop?: boolean;
|
|
96
100
|
}>(), {
|
|
97
101
|
label: undefined,
|
|
98
102
|
value: undefined,
|
|
@@ -144,8 +148,14 @@ function updateDropdownPosition(): void {
|
|
|
144
148
|
};
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
// Value snapshot captured the instant the dropdown opens, used only to freeze the
|
|
152
|
+
// sort order when pullSelectedToTop is set — does not react to later `value` changes
|
|
153
|
+
// while the dropdown stays open.
|
|
154
|
+
const pinnedValue = ref<T | undefined>(undefined);
|
|
155
|
+
|
|
147
156
|
watch(isOpen, (val) => {
|
|
148
157
|
if (val) {
|
|
158
|
+
pinnedValue.value = props.value;
|
|
149
159
|
nextTick(updateDropdownPosition);
|
|
150
160
|
}
|
|
151
161
|
});
|
|
@@ -175,6 +185,23 @@ function isSelected(option: T): boolean {
|
|
|
175
185
|
return toRaw(props.value as object) === toRaw(option as object);
|
|
176
186
|
}
|
|
177
187
|
|
|
188
|
+
function isPinned(option: T): boolean {
|
|
189
|
+
if (pinnedValue.value === undefined) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (props.optionKey) {
|
|
193
|
+
return pinnedValue.value[props.optionKey] === option[props.optionKey];
|
|
194
|
+
}
|
|
195
|
+
return toRaw(pinnedValue.value as object) === toRaw(option as object);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const visibleOptions = computed<T[]>(() => {
|
|
199
|
+
if (!props.pullSelectedToTop) {
|
|
200
|
+
return displayedOptions.value;
|
|
201
|
+
}
|
|
202
|
+
return [...displayedOptions.value.filter(isPinned), ...displayedOptions.value.filter((option) => !isPinned(option))];
|
|
203
|
+
});
|
|
204
|
+
|
|
178
205
|
function selectOption(option: T): void {
|
|
179
206
|
if (isSelected(option) && props.allowDeselect) {
|
|
180
207
|
emit('update:value', undefined);
|