@saooti/octopus-sdk 41.12.0-beta → 41.12.0-beta3
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 +3 -0
- package/package.json +3 -3
- package/src/components/composable/form/useOctopusDropdown.ts +13 -5
- package/src/components/display/podcasts/PodcastPlayButton.vue +3 -3
- package/src/components/form/OctopusMultiselect.vue +157 -49
- package/src/components/form/OctopusSelect.vue +116 -48
- package/src/components/misc/modal/ClassicModal.vue +2 -0
- package/src/components/misc/player/elements/PlayerPlayButton.vue +22 -18
- 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/ParamSdkStore.ts +1 -0
- package/src/stores/class/general/organisation.ts +12 -0
- package/src/stores/class/rubrique/rubrique.ts +1 -1
- package/src/style/_variables.scss +13 -0
- package/src/style/bootstrap.scss +5 -4
- package/tests/components/form/OctopusMultiselect.spec.ts +361 -39
- package/tests/components/form/OctopusSelect.spec.ts +128 -32
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"sessionId":"92d5c13f-8d38-4dc6-a66d-c7256640dc6f","pid":10553,"procStart":"32347","acquiredAt":1783406560303}
|
package/CHANGELOG.md
CHANGED
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
- Mise en commun de code entre `OctopusSelect` & `OctopusMultiselect`
|
|
16
16
|
- Ajustement de `OctopusMultiselect` pour fonctionner correctement avec des
|
|
17
17
|
objets identiques avec références différentes
|
|
18
|
+
- Ajout de nouvelles options à `OctopusMultiselect` pour permettre la saisie
|
|
19
|
+
de données et avoir une meilleure UX en cas de longue liste
|
|
18
20
|
- Ajout de nouvelles classes utilitaires dans le css
|
|
19
21
|
- Ajout de nouvelles options d'apparence pour `ClassicButton`
|
|
20
22
|
- Changement propriété principale de `ClassicButtonGroup` pour plus de cohérence
|
|
23
|
+
- Ajout de variables CSS pour configurer l'aspect de divers boutons
|
|
21
24
|
|
|
22
25
|
**Fixes**
|
|
23
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saooti/octopus-sdk",
|
|
3
|
-
"version": "41.12.0-
|
|
3
|
+
"version": "41.12.0-beta3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Javascript SDK for using octopus",
|
|
6
6
|
"author": "Saooti",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"typescript-eslint": "^8.47.0",
|
|
52
52
|
"video.js": "^8.23.6",
|
|
53
53
|
"videojs-quality-selector-hls": "^1.1.1",
|
|
54
|
-
"vite": "^
|
|
54
|
+
"vite": "^8.0.16",
|
|
55
55
|
"vite-bundle-visualizer": "^1.2.1",
|
|
56
56
|
"vue-material-design-icons": "^5.3.1",
|
|
57
57
|
"vue-recaptcha": "^2.0.3",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@stylistic/eslint-plugin": "*",
|
|
65
65
|
"@types/sockjs-client": "^1.5.4",
|
|
66
66
|
"@types/webpack-env": "^1.18.8",
|
|
67
|
-
"@vitejs/plugin-vue": "^
|
|
67
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
68
68
|
"@vue/test-utils": "^2.4.6",
|
|
69
69
|
"eslint": "^9.39.1",
|
|
70
70
|
"eslint-plugin-vue": "10.9.1",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { computed, getCurrentInstance, nextTick, ref } from 'vue';
|
|
2
|
-
import { onClickOutside } from '@vueuse/core';
|
|
2
|
+
import { type MaybeElementRef, onClickOutside } from '@vueuse/core';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
4
|
|
|
5
5
|
export interface OctopusDropdownProps<T> {
|
|
6
6
|
options: T[];
|
|
7
|
-
optionLabel
|
|
7
|
+
optionLabel?: keyof T & string;
|
|
8
8
|
optionKey?: keyof T;
|
|
9
9
|
isDisabled?: boolean;
|
|
10
10
|
placeholder?: string;
|
|
@@ -13,7 +13,12 @@ export interface OctopusDropdownProps<T> {
|
|
|
13
13
|
export function useOctopusDropdown<T>(
|
|
14
14
|
props: OctopusDropdownProps<T>,
|
|
15
15
|
emitSearch: (query: string) => void,
|
|
16
|
-
idPrefix: string = 'octopus-dropdown'
|
|
16
|
+
idPrefix: string = 'octopus-dropdown',
|
|
17
|
+
// Extra elements to exclude from the click-outside check.
|
|
18
|
+
// Needed when the dropdown is teleported outside containerRef (e.g. to body):
|
|
19
|
+
// without this, clicking inside the teleported dropdown triggers closeDropdown
|
|
20
|
+
// because it is no longer a DOM descendant of containerRef.
|
|
21
|
+
ignore?: MaybeElementRef[]
|
|
17
22
|
) {
|
|
18
23
|
const { t } = useI18n();
|
|
19
24
|
const instance = getCurrentInstance();
|
|
@@ -46,7 +51,10 @@ export function useOctopusDropdown<T>(
|
|
|
46
51
|
const inputPlaceholder = computed(() => props.placeholder ?? t('Search'));
|
|
47
52
|
|
|
48
53
|
function getLabel(option: T): string {
|
|
49
|
-
|
|
54
|
+
if (props.optionLabel) {
|
|
55
|
+
return option[props.optionLabel] as string;
|
|
56
|
+
}
|
|
57
|
+
return option as unknown as string;
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
function openDropdown(): void {
|
|
@@ -78,7 +86,7 @@ export function useOctopusDropdown<T>(
|
|
|
78
86
|
}
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
onClickOutside(containerRef, closeDropdown);
|
|
89
|
+
onClickOutside(containerRef, closeDropdown, ignore?.length ? { ignore } : undefined);
|
|
82
90
|
|
|
83
91
|
return {
|
|
84
92
|
searchQuery,
|
|
@@ -359,15 +359,15 @@ async function play(isVideo: boolean): Promise<void> {
|
|
|
359
359
|
left: 0;
|
|
360
360
|
font-size: 1rem;
|
|
361
361
|
color: white;
|
|
362
|
-
background-color: var(--octopus-
|
|
363
|
-
border-radius: var(--octopus-
|
|
362
|
+
background-color: var(--octopus-btn-play-bg);
|
|
363
|
+
border-radius: var(--octopus-btn-play-radius);
|
|
364
364
|
|
|
365
365
|
@media (width <= 960px) {
|
|
366
366
|
font-size: 0.8rem;
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
button{
|
|
370
|
-
color:
|
|
370
|
+
color: var(--octopus-btn-play-fg);
|
|
371
371
|
background-color: transparent;
|
|
372
372
|
border: 0;
|
|
373
373
|
display: flex;
|
|
@@ -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"
|
|
@@ -58,33 +59,46 @@
|
|
|
58
59
|
{{ allLabelsText }}
|
|
59
60
|
</div>
|
|
60
61
|
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<div class="octopus-multiselect-options">
|
|
62
|
+
<Teleport to=".octopus-app">
|
|
63
|
+
<div
|
|
64
|
+
v-if="isOpen"
|
|
65
|
+
ref="dropdownRef"
|
|
66
|
+
class="octopus-multiselect-dropdown"
|
|
67
|
+
:style="dropdownStyle"
|
|
68
|
+
>
|
|
70
69
|
<ClassicCheckbox
|
|
71
|
-
|
|
72
|
-
:
|
|
73
|
-
:text-init="isSelected(option)"
|
|
74
|
-
:label="getLabel(option)"
|
|
70
|
+
:text-init="allSelected"
|
|
71
|
+
:label="selectAllText ?? t('All')"
|
|
75
72
|
:is-disabled="isDisabled"
|
|
76
|
-
@update:text-init="
|
|
73
|
+
@update:text-init="toggleAll"
|
|
77
74
|
/>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
|
|
76
|
+
<div class="octopus-multiselect-options">
|
|
77
|
+
<ClassicCheckbox
|
|
78
|
+
v-for="(option, index) in visibleOptions"
|
|
79
|
+
:key="index"
|
|
80
|
+
:text-init="isSelected(option)"
|
|
81
|
+
:label="getLabel(option)"
|
|
82
|
+
:is-disabled="isDisabled"
|
|
83
|
+
@update:text-init="toggleOption(option)"
|
|
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">
|
|
92
|
+
{{ t('No elements found. Consider changing the search query.') }}
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
81
95
|
</div>
|
|
82
|
-
</
|
|
96
|
+
</Teleport>
|
|
83
97
|
</div>
|
|
84
98
|
</template>
|
|
85
99
|
|
|
86
100
|
<script setup lang="ts" generic="T">
|
|
87
|
-
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
101
|
+
import { type CSSProperties, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
88
102
|
import { useI18n } from 'vue-i18n';
|
|
89
103
|
import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue';
|
|
90
104
|
import ClassicCheckbox from './ClassicCheckbox.vue';
|
|
@@ -99,8 +113,16 @@ const props = defineProps<{
|
|
|
99
113
|
options: T[];
|
|
100
114
|
/** Key of each option object to use as the ID */
|
|
101
115
|
optionKey?: keyof T;
|
|
102
|
-
/** Key of each option object to use as the display label.
|
|
103
|
-
|
|
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;
|
|
104
126
|
/** Disables the field and all checkboxes when true. */
|
|
105
127
|
isDisabled?: boolean;
|
|
106
128
|
/** Placeholder shown in the input when no items are selected. Defaults to the translated "Search" string. */
|
|
@@ -111,6 +133,10 @@ const props = defineProps<{
|
|
|
111
133
|
noBorder?: boolean;
|
|
112
134
|
/** When true, hovering the closed field with overflow shows a tooltip listing all selected items. */
|
|
113
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;
|
|
114
140
|
}>();
|
|
115
141
|
|
|
116
142
|
const emit = defineEmits<{
|
|
@@ -123,6 +149,10 @@ const emit = defineEmits<{
|
|
|
123
149
|
|
|
124
150
|
const { t } = useI18n();
|
|
125
151
|
|
|
152
|
+
// Ref on the teleported dropdown div — passed as ignored element to useOctopusDropdown
|
|
153
|
+
// so clicks inside the dropdown don't trigger the click-outside handler.
|
|
154
|
+
const dropdownRef = ref<HTMLElement | null>(null);
|
|
155
|
+
|
|
126
156
|
const {
|
|
127
157
|
searchQuery,
|
|
128
158
|
isOpen,
|
|
@@ -136,16 +166,54 @@ const {
|
|
|
136
166
|
openDropdown,
|
|
137
167
|
toggleDropdown,
|
|
138
168
|
handleInput,
|
|
139
|
-
} = useOctopusDropdown(props, (query) => emit('search', query), 'multiselect');
|
|
169
|
+
} = useOctopusDropdown(props, (query) => emit('search', query), 'multiselect', [dropdownRef]);
|
|
140
170
|
|
|
141
171
|
const selectionRef = ref<HTMLElement | null>(null);
|
|
172
|
+
|
|
173
|
+
// Position of the teleported dropdown (position: fixed, anchored below the trigger field)
|
|
174
|
+
const dropdownStyle = ref<CSSProperties>({});
|
|
175
|
+
|
|
176
|
+
function updateDropdownPosition(): void {
|
|
177
|
+
if (!containerRef.value) { return; }
|
|
178
|
+
const rect = containerRef.value.getBoundingClientRect();
|
|
179
|
+
dropdownStyle.value = {
|
|
180
|
+
position: 'fixed',
|
|
181
|
+
top: `${rect.bottom + 2}px`,
|
|
182
|
+
left: `${rect.left}px`,
|
|
183
|
+
width: `${rect.width}px`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
142
186
|
const visibleCount = ref(2);
|
|
143
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
|
+
|
|
144
212
|
const allSelected = computed(() => {
|
|
145
|
-
if (
|
|
213
|
+
if (visibleOptions.value.length === 0) {
|
|
146
214
|
return false;
|
|
147
215
|
}
|
|
148
|
-
return
|
|
216
|
+
return visibleOptions.value.every((option) => isSelected(option));
|
|
149
217
|
});
|
|
150
218
|
|
|
151
219
|
const hasSelected = computed(() => (props.selected?.length ?? 0) > 0);
|
|
@@ -170,6 +238,22 @@ function isSelected(option: T): boolean {
|
|
|
170
238
|
}
|
|
171
239
|
}
|
|
172
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
|
+
|
|
173
257
|
function toggleOption(option: T): void {
|
|
174
258
|
const current = props.selected ?? [];
|
|
175
259
|
if (isSelected(option)) {
|
|
@@ -183,16 +267,27 @@ function toggleOption(option: T): void {
|
|
|
183
267
|
}
|
|
184
268
|
}
|
|
185
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
|
+
|
|
186
281
|
function toggleAll(val: boolean): void {
|
|
187
282
|
const current = props.selected ?? [];
|
|
188
283
|
if (val) {
|
|
189
|
-
const toAdd =
|
|
284
|
+
const toAdd = visibleOptions.value.filter((option: T) => !isSelected(option));
|
|
190
285
|
emit('update:selected', [...current, ...toAdd]);
|
|
191
286
|
} else {
|
|
192
287
|
const key = props.optionKey;
|
|
193
288
|
emit('update:selected', current.filter((item: T) => key
|
|
194
|
-
? !
|
|
195
|
-
: !
|
|
289
|
+
? !visibleOptions.value.some((opt) => opt[key] === item[key])
|
|
290
|
+
: !visibleOptions.value.includes(item)
|
|
196
291
|
));
|
|
197
292
|
}
|
|
198
293
|
}
|
|
@@ -247,16 +342,24 @@ onMounted(() => {
|
|
|
247
342
|
resizeObserver.observe(selectionRef.value);
|
|
248
343
|
}
|
|
249
344
|
updateVisibleCount();
|
|
345
|
+
// Keep the teleported dropdown aligned when the page scrolls or the viewport resizes
|
|
346
|
+
window.addEventListener('scroll', updateDropdownPosition, true);
|
|
347
|
+
window.addEventListener('resize', updateDropdownPosition);
|
|
250
348
|
});
|
|
251
349
|
|
|
252
350
|
onUnmounted(() => {
|
|
253
351
|
resizeObserver?.disconnect();
|
|
352
|
+
window.removeEventListener('scroll', updateDropdownPosition, true);
|
|
353
|
+
window.removeEventListener('resize', updateDropdownPosition);
|
|
254
354
|
});
|
|
255
355
|
|
|
256
356
|
watch(() => props.selected, updateVisibleCount);
|
|
257
357
|
|
|
258
358
|
watch(isOpen, (val) => {
|
|
259
|
-
if (
|
|
359
|
+
if (val) {
|
|
360
|
+
pinnedSnapshot.value = [...(props.selected ?? [])];
|
|
361
|
+
nextTick(updateDropdownPosition);
|
|
362
|
+
} else {
|
|
260
363
|
nextTick(updateVisibleCount);
|
|
261
364
|
}
|
|
262
365
|
});
|
|
@@ -345,31 +448,36 @@ watch(isOpen, (val) => {
|
|
|
345
448
|
align-items: center;
|
|
346
449
|
}
|
|
347
450
|
|
|
348
|
-
|
|
349
|
-
position: absolute;
|
|
350
|
-
top: calc(100% + 2px);
|
|
351
|
-
left: 0;
|
|
352
|
-
right: 0;
|
|
353
|
-
z-index: 100;
|
|
354
|
-
background: white;
|
|
355
|
-
border: 1px solid var(--octopus-border-default);
|
|
356
|
-
border-radius: var(--octopus-border-radius);
|
|
357
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
358
|
-
padding: 0.25rem 0;
|
|
451
|
+
}
|
|
359
452
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
453
|
+
// Dropdown is teleported to body — scoped rules must be top-level so that [data-v-xxxx]
|
|
454
|
+
// is matched directly on the element rather than via a descendant-of-.octopus-multiselect selector.
|
|
455
|
+
.octopus-multiselect-dropdown {
|
|
456
|
+
z-index: 100;
|
|
457
|
+
background: white;
|
|
458
|
+
border: 1px solid var(--octopus-border-default);
|
|
459
|
+
border-radius: var(--octopus-border-radius);
|
|
460
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
461
|
+
padding: 0.25rem 0;
|
|
462
|
+
|
|
463
|
+
> .octopus-form-item {
|
|
464
|
+
padding: 0.25rem 0.5rem;
|
|
465
|
+
border-bottom: 1px solid var(--octopus-secondary);
|
|
364
466
|
}
|
|
467
|
+
}
|
|
365
468
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
469
|
+
.octopus-multiselect-options {
|
|
470
|
+
max-height: 14rem;
|
|
471
|
+
overflow-y: auto;
|
|
369
472
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
473
|
+
.octopus-form-item {
|
|
474
|
+
padding: 0.25rem 0.5rem;
|
|
373
475
|
}
|
|
374
476
|
}
|
|
477
|
+
|
|
478
|
+
hr {
|
|
479
|
+
border-top: 1px solid var(--octopus-secondary);
|
|
480
|
+
border-bottom: none;
|
|
481
|
+
margin: 0;
|
|
482
|
+
}
|
|
375
483
|
</style>
|
|
@@ -42,27 +42,34 @@
|
|
|
42
42
|
</button>
|
|
43
43
|
</div>
|
|
44
44
|
|
|
45
|
-
<
|
|
46
|
-
<div
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
45
|
+
<Teleport to=".octopus-app">
|
|
46
|
+
<div
|
|
47
|
+
v-if="isOpen"
|
|
48
|
+
ref="dropdownRef"
|
|
49
|
+
class="octopus-select-dropdown"
|
|
50
|
+
:style="dropdownStyle"
|
|
51
|
+
>
|
|
52
|
+
<div class="octopus-select-options">
|
|
53
|
+
<button
|
|
54
|
+
v-for="(option, index) in visibleOptions"
|
|
55
|
+
:key="index"
|
|
56
|
+
class="octopus-select-option"
|
|
57
|
+
:class="{ selected: isSelected(option) }"
|
|
58
|
+
@click="selectOption(option)"
|
|
59
|
+
>
|
|
60
|
+
{{ getLabel(option) }}
|
|
61
|
+
</button>
|
|
62
|
+
<span v-if="visibleOptions.length === 0" class="text-indic px-2">
|
|
63
|
+
{{ t('No elements found. Consider changing the search query.') }}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
59
66
|
</div>
|
|
60
|
-
</
|
|
67
|
+
</Teleport>
|
|
61
68
|
</div>
|
|
62
69
|
</template>
|
|
63
70
|
|
|
64
71
|
<script setup lang="ts" generic="T">
|
|
65
|
-
import { computed, toRaw } from 'vue';
|
|
72
|
+
import { type CSSProperties, computed, nextTick, onMounted, onUnmounted, ref, toRaw, watch } from 'vue';
|
|
66
73
|
import { useI18n } from 'vue-i18n';
|
|
67
74
|
import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue';
|
|
68
75
|
import { useOctopusDropdown } from '../composable/form/useOctopusDropdown';
|
|
@@ -86,6 +93,10 @@ const props = withDefaults(defineProps<{
|
|
|
86
93
|
noBorder?: boolean;
|
|
87
94
|
/** When true (default), clicking the already-selected option clears the selection. */
|
|
88
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;
|
|
89
100
|
}>(), {
|
|
90
101
|
label: undefined,
|
|
91
102
|
value: undefined,
|
|
@@ -104,6 +115,10 @@ const emit = defineEmits<{
|
|
|
104
115
|
|
|
105
116
|
const { t } = useI18n();
|
|
106
117
|
|
|
118
|
+
// Ref on the teleported dropdown div — passed as ignored element to useOctopusDropdown
|
|
119
|
+
// so clicks inside the dropdown don't trigger the click-outside handler.
|
|
120
|
+
const dropdownRef = ref<HTMLElement | null>(null);
|
|
121
|
+
|
|
107
122
|
const {
|
|
108
123
|
searchQuery,
|
|
109
124
|
isOpen,
|
|
@@ -117,7 +132,44 @@ const {
|
|
|
117
132
|
closeDropdown,
|
|
118
133
|
toggleDropdown,
|
|
119
134
|
handleInput,
|
|
120
|
-
} = useOctopusDropdown(props, (query) => emit('search', query), 'select');
|
|
135
|
+
} = useOctopusDropdown(props, (query) => emit('search', query), 'select', [dropdownRef]);
|
|
136
|
+
|
|
137
|
+
// Position of the teleported dropdown (position: fixed, anchored below the trigger field)
|
|
138
|
+
const dropdownStyle = ref<CSSProperties>({});
|
|
139
|
+
|
|
140
|
+
function updateDropdownPosition(): void {
|
|
141
|
+
if (!containerRef.value) { return; }
|
|
142
|
+
const rect = containerRef.value.getBoundingClientRect();
|
|
143
|
+
dropdownStyle.value = {
|
|
144
|
+
position: 'fixed',
|
|
145
|
+
top: `${rect.bottom + 2}px`,
|
|
146
|
+
left: `${rect.left}px`,
|
|
147
|
+
width: `${rect.width}px`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
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
|
+
|
|
156
|
+
watch(isOpen, (val) => {
|
|
157
|
+
if (val) {
|
|
158
|
+
pinnedValue.value = props.value;
|
|
159
|
+
nextTick(updateDropdownPosition);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
onMounted(() => {
|
|
164
|
+
// Keep the teleported dropdown aligned when the page scrolls or the viewport resizes
|
|
165
|
+
window.addEventListener('scroll', updateDropdownPosition, true);
|
|
166
|
+
window.addEventListener('resize', updateDropdownPosition);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
onUnmounted(() => {
|
|
170
|
+
window.removeEventListener('scroll', updateDropdownPosition, true);
|
|
171
|
+
window.removeEventListener('resize', updateDropdownPosition);
|
|
172
|
+
});
|
|
121
173
|
|
|
122
174
|
const selectedLabel = computed(() =>
|
|
123
175
|
props.value !== undefined ? getLabel(props.value) : undefined
|
|
@@ -133,6 +185,23 @@ function isSelected(option: T): boolean {
|
|
|
133
185
|
return toRaw(props.value as object) === toRaw(option as object);
|
|
134
186
|
}
|
|
135
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
|
+
|
|
136
205
|
function selectOption(option: T): void {
|
|
137
206
|
if (isSelected(option) && props.allowDeselect) {
|
|
138
207
|
emit('update:value', undefined);
|
|
@@ -197,41 +266,40 @@ function selectOption(option: T): void {
|
|
|
197
266
|
align-items: center;
|
|
198
267
|
}
|
|
199
268
|
|
|
200
|
-
|
|
201
|
-
position: absolute;
|
|
202
|
-
top: calc(100% + 2px);
|
|
203
|
-
left: 0;
|
|
204
|
-
right: 0;
|
|
205
|
-
z-index: 100;
|
|
206
|
-
background: white;
|
|
207
|
-
border: 1px solid var(--octopus-border-default);
|
|
208
|
-
border-radius: var(--octopus-border-radius);
|
|
209
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
210
|
-
padding: 0.25rem 0;
|
|
211
|
-
}
|
|
269
|
+
}
|
|
212
270
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
271
|
+
// Dropdown is teleported to body — scoped rules must be top-level so that [data-v-xxxx]
|
|
272
|
+
// is matched directly on the element rather than via a descendant-of-.octopus-select selector.
|
|
273
|
+
.octopus-select-dropdown {
|
|
274
|
+
z-index: 100;
|
|
275
|
+
background: white;
|
|
276
|
+
border: 1px solid var(--octopus-border-default);
|
|
277
|
+
border-radius: var(--octopus-border-radius);
|
|
278
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
279
|
+
padding: 0.25rem 0;
|
|
280
|
+
}
|
|
217
281
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
padding: 0.25rem 0.5rem;
|
|
223
|
-
border: none;
|
|
224
|
-
background: transparent;
|
|
225
|
-
cursor: pointer;
|
|
282
|
+
.octopus-select-options {
|
|
283
|
+
max-height: 14rem;
|
|
284
|
+
overflow-y: auto;
|
|
285
|
+
}
|
|
226
286
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
287
|
+
.octopus-select-option {
|
|
288
|
+
display: block;
|
|
289
|
+
width: 100%;
|
|
290
|
+
text-align: left;
|
|
291
|
+
padding: 0.25rem 0.5rem;
|
|
292
|
+
border: none;
|
|
293
|
+
background: transparent;
|
|
294
|
+
cursor: pointer;
|
|
230
295
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
296
|
+
&:hover {
|
|
297
|
+
background: var(--octopus-secondary-lighter);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
&.selected {
|
|
301
|
+
font-weight: 600;
|
|
302
|
+
color: var(--octopus-primary);
|
|
235
303
|
}
|
|
236
304
|
}
|
|
237
305
|
</style>
|