@saooti/octopus-sdk 41.11.1 → 41.12.0-beta2
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/CHANGELOG.md +29 -0
- package/eslint-config.mjs +10 -2
- package/index.ts +5 -2
- package/package.json +4 -1
- package/src/api/radioApi.ts +14 -1
- package/src/components/buttons/ClassicButton.vue +18 -1
- package/src/components/composable/form/useOctopusDropdown.ts +104 -0
- package/src/components/composable/useSticky.ts +45 -0
- package/src/components/display/emission/EmissionGroupChooser.vue +22 -24
- package/src/components/display/podcasts/PodcastPlayButton.vue +3 -3
- package/src/components/form/ClassicButtonGroup.vue +15 -13
- package/src/components/form/ClassicSelect.vue +11 -2
- package/src/components/form/OctopusMultiselect.vue +236 -109
- package/src/components/form/OctopusSelect.vue +278 -0
- package/src/components/misc/ClassicPopover.vue +9 -2
- package/src/components/misc/modal/ClassicModal.vue +3 -0
- package/src/components/misc/player/elements/PlayerPlayButton.vue +22 -18
- package/src/components/pages/PlaylistsPage.vue +1 -0
- package/src/helper/colorFromString.ts +12 -5
- package/src/helper/equals.ts +21 -9
- package/src/stores/ParamSdkStore.ts +1 -0
- package/src/stores/class/general/organisation.ts +6 -0
- package/src/stores/class/general/podcast.ts +8 -0
- package/src/stores/class/rubrique/rubrique.ts +1 -1
- package/src/style/_utilities.scss +1 -0
- package/src/style/_variables.scss +11 -0
- package/src/style/bootstrap.scss +9 -2
- package/tests/components/form/ClassicButtonGroup.spec.ts +10 -10
- package/tests/components/form/OctopusMultiselect.spec.ts +84 -39
- package/tests/components/form/OctopusSelect.spec.ts +168 -0
- package/tsconfig.json +3 -7
- package/tsconfig.test.json +13 -0
- package/vitest.config.js +2 -1
|
@@ -16,8 +16,22 @@
|
|
|
16
16
|
class="octopus-multiselect-field"
|
|
17
17
|
:class="{ disabled: isDisabled, open: isOpen, noBorder }"
|
|
18
18
|
@click="openDropdown"
|
|
19
|
+
@mouseenter="isHovered = true"
|
|
20
|
+
@mouseleave="isHovered = false"
|
|
19
21
|
>
|
|
22
|
+
<div
|
|
23
|
+
v-show="hasSelected && !isOpen"
|
|
24
|
+
ref="selectionRef"
|
|
25
|
+
class="octopus-multiselect-selection"
|
|
26
|
+
>
|
|
27
|
+
<span class="octopus-multiselect-selection-text">{{ selectionLabels }}</span>
|
|
28
|
+
<span
|
|
29
|
+
v-if="overflowCount > 0"
|
|
30
|
+
class="octopus-multiselect-selection-count"
|
|
31
|
+
>(+{{ overflowCount }})</span>
|
|
32
|
+
</div>
|
|
20
33
|
<input
|
|
34
|
+
v-show="!hasSelected || isOpen"
|
|
21
35
|
:id="computedId"
|
|
22
36
|
ref="inputRef"
|
|
23
37
|
v-model="searchQuery"
|
|
@@ -37,37 +51,51 @@
|
|
|
37
51
|
</button>
|
|
38
52
|
</div>
|
|
39
53
|
|
|
40
|
-
<div
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/>
|
|
54
|
+
<div
|
|
55
|
+
v-if="expandOnHover && isHovered && !isOpen && overflowCount > 0"
|
|
56
|
+
class="octopus-multiselect-hover-tooltip"
|
|
57
|
+
>
|
|
58
|
+
{{ allLabelsText }}
|
|
59
|
+
</div>
|
|
47
60
|
|
|
48
|
-
|
|
61
|
+
<Teleport to=".octopus-app">
|
|
62
|
+
<div
|
|
63
|
+
v-if="isOpen"
|
|
64
|
+
ref="dropdownRef"
|
|
65
|
+
class="octopus-multiselect-dropdown"
|
|
66
|
+
:style="dropdownStyle"
|
|
67
|
+
>
|
|
49
68
|
<ClassicCheckbox
|
|
50
|
-
|
|
51
|
-
:
|
|
52
|
-
:text-init="isSelected(option)"
|
|
53
|
-
:label="getLabel(option)"
|
|
69
|
+
:text-init="allSelected"
|
|
70
|
+
:label="selectAllText ?? t('All')"
|
|
54
71
|
:is-disabled="isDisabled"
|
|
55
|
-
@update:text-init="
|
|
72
|
+
@update:text-init="toggleAll"
|
|
56
73
|
/>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
|
|
75
|
+
<div class="octopus-multiselect-options">
|
|
76
|
+
<ClassicCheckbox
|
|
77
|
+
v-for="(option, index) in displayedOptions"
|
|
78
|
+
:key="index"
|
|
79
|
+
:text-init="isSelected(option)"
|
|
80
|
+
:label="getLabel(option)"
|
|
81
|
+
:is-disabled="isDisabled"
|
|
82
|
+
@update:text-init="toggleOption(option)"
|
|
83
|
+
/>
|
|
84
|
+
<span v-if="displayedOptions.length === 0" class="text-indic px-2">
|
|
85
|
+
{{ t('No elements found. Consider changing the search query.') }}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
60
88
|
</div>
|
|
61
|
-
</
|
|
89
|
+
</Teleport>
|
|
62
90
|
</div>
|
|
63
91
|
</template>
|
|
64
92
|
|
|
65
93
|
<script setup lang="ts" generic="T">
|
|
66
|
-
import {
|
|
67
|
-
import { onClickOutside } from '@vueuse/core';
|
|
94
|
+
import { type CSSProperties, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
68
95
|
import { useI18n } from 'vue-i18n';
|
|
69
96
|
import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue';
|
|
70
97
|
import ClassicCheckbox from './ClassicCheckbox.vue';
|
|
98
|
+
import { useOctopusDropdown } from '../composable/form/useOctopusDropdown';
|
|
71
99
|
|
|
72
100
|
const props = defineProps<{
|
|
73
101
|
/** Optional label displayed above the field. */
|
|
@@ -76,6 +104,8 @@ const props = defineProps<{
|
|
|
76
104
|
selected?: T[];
|
|
77
105
|
/** Full list of options to display or filter. */
|
|
78
106
|
options: T[];
|
|
107
|
+
/** Key of each option object to use as the ID */
|
|
108
|
+
optionKey?: keyof T;
|
|
79
109
|
/** Key of each option object to use as the display label. */
|
|
80
110
|
optionLabel: keyof T & string;
|
|
81
111
|
/** Disables the field and all checkboxes when true. */
|
|
@@ -84,47 +114,57 @@ const props = defineProps<{
|
|
|
84
114
|
placeholder?: string;
|
|
85
115
|
/** Label for the "select all" checkbox. Defaults to the translated "All" string. */
|
|
86
116
|
selectAllText?: string;
|
|
87
|
-
/** If provided, called on every input change; its return value replaces the displayed options. */
|
|
88
|
-
onSearch?: (query: string) => T[] | Promise<T[]>;
|
|
89
117
|
/** Disable the border around the input */
|
|
90
118
|
noBorder?: boolean;
|
|
119
|
+
/** When true, hovering the closed field with overflow shows a tooltip listing all selected items. */
|
|
120
|
+
expandOnHover?: boolean;
|
|
91
121
|
}>();
|
|
92
122
|
|
|
93
123
|
const emit = defineEmits<{
|
|
94
124
|
/** Emitted when the selection changes. */
|
|
95
125
|
(e: 'update:selected', value: T[]): void;
|
|
126
|
+
/** Emitted on every input change. When listened to, the parent is responsible for
|
|
127
|
+
* updating `options`; otherwise the component filters `options` client-side. */
|
|
128
|
+
(e: 'search', query: string): void;
|
|
96
129
|
}>();
|
|
97
130
|
|
|
98
131
|
const { t } = useI18n();
|
|
99
132
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
);
|
|
127
|
-
|
|
133
|
+
// Ref on the teleported dropdown div — passed as ignored element to useOctopusDropdown
|
|
134
|
+
// so clicks inside the dropdown don't trigger the click-outside handler.
|
|
135
|
+
const dropdownRef = ref<HTMLElement | null>(null);
|
|
136
|
+
|
|
137
|
+
const {
|
|
138
|
+
searchQuery,
|
|
139
|
+
isOpen,
|
|
140
|
+
isHovered,
|
|
141
|
+
containerRef,
|
|
142
|
+
inputRef,
|
|
143
|
+
computedId,
|
|
144
|
+
displayedOptions,
|
|
145
|
+
inputPlaceholder,
|
|
146
|
+
getLabel,
|
|
147
|
+
openDropdown,
|
|
148
|
+
toggleDropdown,
|
|
149
|
+
handleInput,
|
|
150
|
+
} = useOctopusDropdown(props, (query) => emit('search', query), 'multiselect', [dropdownRef]);
|
|
151
|
+
|
|
152
|
+
const selectionRef = ref<HTMLElement | null>(null);
|
|
153
|
+
|
|
154
|
+
// Position of the teleported dropdown (position: fixed, anchored below the trigger field)
|
|
155
|
+
const dropdownStyle = ref<CSSProperties>({});
|
|
156
|
+
|
|
157
|
+
function updateDropdownPosition(): void {
|
|
158
|
+
if (!containerRef.value) { return; }
|
|
159
|
+
const rect = containerRef.value.getBoundingClientRect();
|
|
160
|
+
dropdownStyle.value = {
|
|
161
|
+
position: 'fixed',
|
|
162
|
+
top: `${rect.bottom + 2}px`,
|
|
163
|
+
left: `${rect.left}px`,
|
|
164
|
+
width: `${rect.width}px`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const visibleCount = ref(2);
|
|
128
168
|
|
|
129
169
|
const allSelected = computed(() => {
|
|
130
170
|
if (displayedOptions.value.length === 0) {
|
|
@@ -133,30 +173,36 @@ const allSelected = computed(() => {
|
|
|
133
173
|
return displayedOptions.value.every((option) => isSelected(option));
|
|
134
174
|
});
|
|
135
175
|
|
|
136
|
-
const
|
|
137
|
-
const selected = props.selected ?? [];
|
|
138
|
-
if (selected.length === 0) {
|
|
139
|
-
return props.placeholder ?? t('Search');
|
|
140
|
-
}
|
|
141
|
-
const labels = selected.map(getLabel);
|
|
142
|
-
if (labels.length <= 2) {
|
|
143
|
-
return labels.join(', ');
|
|
144
|
-
}
|
|
145
|
-
return `${labels.slice(0, 2).join(', ')} (+${labels.length - 2})`;
|
|
146
|
-
});
|
|
176
|
+
const hasSelected = computed(() => (props.selected?.length ?? 0) > 0);
|
|
147
177
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
178
|
+
const selectionLabels = computed(() =>
|
|
179
|
+
(props.selected ?? []).slice(0, visibleCount.value).map(getLabel).join(', ')
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const overflowCount = computed(() =>
|
|
183
|
+
Math.max(0, (props.selected?.length ?? 0) - visibleCount.value)
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const allLabelsText = computed(() =>
|
|
187
|
+
(props.selected ?? []).map(getLabel).join(', ')
|
|
188
|
+
);
|
|
151
189
|
|
|
152
190
|
function isSelected(option: T): boolean {
|
|
153
|
-
|
|
191
|
+
if (props.optionKey) {
|
|
192
|
+
return props.selected?.find(s => s[props.optionKey] === option[props.optionKey]) !== undefined;
|
|
193
|
+
} else {
|
|
194
|
+
return props.selected?.includes(option) ?? false;
|
|
195
|
+
}
|
|
154
196
|
}
|
|
155
197
|
|
|
156
198
|
function toggleOption(option: T): void {
|
|
157
199
|
const current = props.selected ?? [];
|
|
158
200
|
if (isSelected(option)) {
|
|
159
|
-
|
|
201
|
+
const key = props.optionKey;
|
|
202
|
+
emit('update:selected', key
|
|
203
|
+
? current.filter((item) => item[key] !== option[key])
|
|
204
|
+
: current.filter((item) => item !== option)
|
|
205
|
+
);
|
|
160
206
|
} else {
|
|
161
207
|
emit('update:selected', [...current, option]);
|
|
162
208
|
}
|
|
@@ -168,45 +214,87 @@ function toggleAll(val: boolean): void {
|
|
|
168
214
|
const toAdd = displayedOptions.value.filter((option: T) => !isSelected(option));
|
|
169
215
|
emit('update:selected', [...current, ...toAdd]);
|
|
170
216
|
} else {
|
|
171
|
-
|
|
217
|
+
const key = props.optionKey;
|
|
218
|
+
emit('update:selected', current.filter((item: T) => key
|
|
219
|
+
? !displayedOptions.value.some((opt) => opt[key] === item[key])
|
|
220
|
+
: !displayedOptions.value.includes(item)
|
|
221
|
+
));
|
|
172
222
|
}
|
|
173
223
|
}
|
|
174
224
|
|
|
175
|
-
function
|
|
176
|
-
|
|
225
|
+
function updateVisibleCount(): void {
|
|
226
|
+
const container = selectionRef.value;
|
|
227
|
+
const selected = props.selected ?? [];
|
|
228
|
+
if (!container || selected.length < 2) {
|
|
229
|
+
visibleCount.value = selected.length;
|
|
177
230
|
return;
|
|
178
231
|
}
|
|
179
|
-
isOpen.value = true;
|
|
180
|
-
nextTick(() => {
|
|
181
|
-
inputRef.value?.focus();
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function closeDropdown(): void {
|
|
186
|
-
isOpen.value = false;
|
|
187
|
-
searchQuery.value = '';
|
|
188
|
-
}
|
|
189
232
|
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
} else {
|
|
194
|
-
openDropdown();
|
|
233
|
+
const availableWidth = container.offsetWidth;
|
|
234
|
+
if (availableWidth === 0) {
|
|
235
|
+
return;
|
|
195
236
|
}
|
|
196
|
-
}
|
|
197
237
|
|
|
198
|
-
|
|
199
|
-
|
|
238
|
+
const labels = selected.map(getLabel);
|
|
239
|
+
const measurer = document.createElement('span');
|
|
240
|
+
measurer.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;pointer-events:none;';
|
|
241
|
+
container.appendChild(measurer);
|
|
242
|
+
|
|
243
|
+
measurer.textContent = labels.join(', ');
|
|
244
|
+
if (measurer.offsetWidth <= availableWidth) {
|
|
245
|
+
visibleCount.value = labels.length;
|
|
246
|
+
container.removeChild(measurer);
|
|
200
247
|
return;
|
|
201
248
|
}
|
|
202
|
-
|
|
203
|
-
|
|
249
|
+
|
|
250
|
+
measurer.textContent = `(+${labels.length})`;
|
|
251
|
+
const badgeWidth = measurer.offsetWidth + 4;
|
|
252
|
+
const textAvailable = availableWidth - badgeWidth;
|
|
253
|
+
|
|
254
|
+
let count = 0;
|
|
255
|
+
for (let i = 0; i < labels.length; i++) {
|
|
256
|
+
measurer.textContent = labels.slice(0, i + 1).join(', ');
|
|
257
|
+
if (measurer.offsetWidth > textAvailable) {
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
count = i + 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
container.removeChild(measurer);
|
|
264
|
+
visibleCount.value = Math.max(1, count);
|
|
204
265
|
}
|
|
205
266
|
|
|
206
|
-
|
|
267
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
268
|
+
|
|
269
|
+
onMounted(() => {
|
|
270
|
+
if (selectionRef.value) {
|
|
271
|
+
resizeObserver = new ResizeObserver(updateVisibleCount);
|
|
272
|
+
resizeObserver.observe(selectionRef.value);
|
|
273
|
+
}
|
|
274
|
+
updateVisibleCount();
|
|
275
|
+
// Keep the teleported dropdown aligned when the page scrolls or the viewport resizes
|
|
276
|
+
window.addEventListener('scroll', updateDropdownPosition, true);
|
|
277
|
+
window.addEventListener('resize', updateDropdownPosition);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
onUnmounted(() => {
|
|
281
|
+
resizeObserver?.disconnect();
|
|
282
|
+
window.removeEventListener('scroll', updateDropdownPosition, true);
|
|
283
|
+
window.removeEventListener('resize', updateDropdownPosition);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
watch(() => props.selected, updateVisibleCount);
|
|
287
|
+
|
|
288
|
+
watch(isOpen, (val) => {
|
|
289
|
+
if (val) {
|
|
290
|
+
nextTick(updateDropdownPosition);
|
|
291
|
+
} else {
|
|
292
|
+
nextTick(updateVisibleCount);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
207
295
|
</script>
|
|
208
296
|
|
|
209
|
-
<style lang="scss">
|
|
297
|
+
<style scoped lang="scss">
|
|
210
298
|
.octopus-multiselect {
|
|
211
299
|
position: relative;
|
|
212
300
|
|
|
@@ -232,48 +320,87 @@ onClickOutside(containerRef, closeDropdown);
|
|
|
232
320
|
}
|
|
233
321
|
}
|
|
234
322
|
|
|
323
|
+
.octopus-multiselect-selection {
|
|
324
|
+
display: flex;
|
|
325
|
+
align-items: center;
|
|
326
|
+
flex: 1;
|
|
327
|
+
min-width: 0;
|
|
328
|
+
padding: 0.4rem 0.5rem;
|
|
329
|
+
height: 2rem;
|
|
330
|
+
gap: 0.25rem;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.octopus-multiselect-selection-text {
|
|
334
|
+
flex: 1;
|
|
335
|
+
min-width: 0;
|
|
336
|
+
overflow: hidden;
|
|
337
|
+
text-overflow: ellipsis;
|
|
338
|
+
white-space: nowrap;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.octopus-multiselect-selection-count {
|
|
342
|
+
flex-shrink: 0;
|
|
343
|
+
white-space: nowrap;
|
|
344
|
+
color: var(--octopus-primary);
|
|
345
|
+
}
|
|
346
|
+
|
|
235
347
|
.octopus-multiselect-input {
|
|
236
348
|
flex: 1;
|
|
237
349
|
border: none;
|
|
238
350
|
background: transparent;
|
|
239
351
|
padding: 0.4rem 0.5rem;
|
|
352
|
+
padding-right: 0;
|
|
240
353
|
height: 2rem;
|
|
241
354
|
outline: none;
|
|
242
355
|
cursor: inherit;
|
|
243
356
|
min-width: 0;
|
|
244
357
|
}
|
|
245
358
|
|
|
246
|
-
.octopus-multiselect-
|
|
247
|
-
padding: 0.25rem 0.5rem;
|
|
248
|
-
display: flex;
|
|
249
|
-
align-items: center;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
.octopus-multiselect-dropdown {
|
|
359
|
+
.octopus-multiselect-hover-tooltip {
|
|
253
360
|
position: absolute;
|
|
254
361
|
top: calc(100% + 2px);
|
|
255
362
|
left: 0;
|
|
256
363
|
right: 0;
|
|
257
|
-
z-index:
|
|
364
|
+
z-index: 101;
|
|
258
365
|
background: white;
|
|
259
366
|
border: 1px solid var(--octopus-border-default);
|
|
260
367
|
border-radius: var(--octopus-border-radius);
|
|
261
368
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
262
|
-
padding: 0.
|
|
369
|
+
padding: 0.5rem;
|
|
370
|
+
word-break: break-word;
|
|
371
|
+
pointer-events: none;
|
|
372
|
+
}
|
|
263
373
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
374
|
+
.octopus-multiselect-chevron {
|
|
375
|
+
padding: 0.25rem 0.5rem;
|
|
376
|
+
display: flex;
|
|
377
|
+
align-items: center;
|
|
268
378
|
}
|
|
269
379
|
|
|
270
|
-
|
|
271
|
-
max-height: 14rem;
|
|
272
|
-
overflow-y: auto;
|
|
380
|
+
}
|
|
273
381
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
382
|
+
// Dropdown is teleported to body — scoped rules must be top-level so that [data-v-xxxx]
|
|
383
|
+
// is matched directly on the element rather than via a descendant-of-.octopus-multiselect selector.
|
|
384
|
+
.octopus-multiselect-dropdown {
|
|
385
|
+
z-index: 100;
|
|
386
|
+
background: white;
|
|
387
|
+
border: 1px solid var(--octopus-border-default);
|
|
388
|
+
border-radius: var(--octopus-border-radius);
|
|
389
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
390
|
+
padding: 0.25rem 0;
|
|
391
|
+
|
|
392
|
+
> .octopus-form-item {
|
|
393
|
+
padding: 0.25rem 0.5rem;
|
|
394
|
+
border-bottom: 1px solid var(--octopus-secondary);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.octopus-multiselect-options {
|
|
399
|
+
max-height: 14rem;
|
|
400
|
+
overflow-y: auto;
|
|
401
|
+
|
|
402
|
+
.octopus-form-item {
|
|
403
|
+
padding: 0.25rem 0.5rem;
|
|
277
404
|
}
|
|
278
405
|
}
|
|
279
406
|
</style>
|