@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/eslint-config.mjs +10 -2
  3. package/index.ts +5 -2
  4. package/package.json +4 -1
  5. package/src/api/radioApi.ts +14 -1
  6. package/src/components/buttons/ClassicButton.vue +18 -1
  7. package/src/components/composable/form/useOctopusDropdown.ts +104 -0
  8. package/src/components/composable/useSticky.ts +45 -0
  9. package/src/components/display/emission/EmissionGroupChooser.vue +22 -24
  10. package/src/components/display/podcasts/PodcastPlayButton.vue +3 -3
  11. package/src/components/form/ClassicButtonGroup.vue +15 -13
  12. package/src/components/form/ClassicSelect.vue +11 -2
  13. package/src/components/form/OctopusMultiselect.vue +236 -109
  14. package/src/components/form/OctopusSelect.vue +278 -0
  15. package/src/components/misc/ClassicPopover.vue +9 -2
  16. package/src/components/misc/modal/ClassicModal.vue +3 -0
  17. package/src/components/misc/player/elements/PlayerPlayButton.vue +22 -18
  18. package/src/components/pages/PlaylistsPage.vue +1 -0
  19. package/src/helper/colorFromString.ts +12 -5
  20. package/src/helper/equals.ts +21 -9
  21. package/src/stores/ParamSdkStore.ts +1 -0
  22. package/src/stores/class/general/organisation.ts +6 -0
  23. package/src/stores/class/general/podcast.ts +8 -0
  24. package/src/stores/class/rubrique/rubrique.ts +1 -1
  25. package/src/style/_utilities.scss +1 -0
  26. package/src/style/_variables.scss +11 -0
  27. package/src/style/bootstrap.scss +9 -2
  28. package/tests/components/form/ClassicButtonGroup.spec.ts +10 -10
  29. package/tests/components/form/OctopusMultiselect.spec.ts +84 -39
  30. package/tests/components/form/OctopusSelect.spec.ts +168 -0
  31. package/tsconfig.json +3 -7
  32. package/tsconfig.test.json +13 -0
  33. 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 v-if="isOpen" class="octopus-multiselect-dropdown">
41
- <ClassicCheckbox
42
- :text-init="allSelected"
43
- :label="selectAllText ?? t('All')"
44
- :is-disabled="isDisabled"
45
- @update:text-init="toggleAll"
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
- <div class="octopus-multiselect-options">
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
- v-for="(option, index) in displayedOptions"
51
- :key="index"
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="toggleOption(option)"
72
+ @update:text-init="toggleAll"
56
73
  />
57
- <span v-if="displayedOptions.length === 0" class="text-indic px-2">
58
- {{ t('No elements found. Consider changing the search query.') }}
59
- </span>
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
- </div>
89
+ </Teleport>
62
90
  </div>
63
91
  </template>
64
92
 
65
93
  <script setup lang="ts" generic="T">
66
- import { computed, getCurrentInstance, nextTick, ref, shallowRef, watch } from 'vue';
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
- const searchQuery = ref('');
101
- const isOpen = ref(false);
102
- const internalOptions = shallowRef<T[]>([]);
103
- const containerRef = ref<HTMLElement | null>(null);
104
- const inputRef = ref<HTMLInputElement | null>(null);
105
-
106
- const computedId = computed(() => 'multiselect-' + getCurrentInstance()?.uid);
107
-
108
- watch(
109
- () => props.options,
110
- (val) => {
111
- internalOptions.value = val;
112
- },
113
- { immediate: true }
114
- );
115
-
116
- const displayedOptions = computed((): Array<T> => {
117
- if (props.onSearch) {
118
- return internalOptions.value;
119
- }
120
- const query = searchQuery.value.toLowerCase();
121
- if (!query) {
122
- return props.options;
123
- }
124
- return props.options.filter((option) =>
125
- getLabel(option).toLowerCase().includes(query)
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 inputPlaceholder = computed(() => {
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
- function getLabel(option: T): string {
149
- return option[props.optionLabel] as string;
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
- return props.selected?.includes(option) ?? false;
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
- emit('update:selected', current.filter((item) => item !== option));
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
- emit('update:selected', current.filter((item: T) => !displayedOptions.value.includes(item)));
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 openDropdown(): void {
176
- if (props.isDisabled) {
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
- function toggleDropdown(): void {
191
- if (isOpen.value) {
192
- closeDropdown();
193
- } else {
194
- openDropdown();
233
+ const availableWidth = container.offsetWidth;
234
+ if (availableWidth === 0) {
235
+ return;
195
236
  }
196
- }
197
237
 
198
- async function handleInput(): Promise<void> {
199
- if (!props.onSearch) {
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
- const result = await props.onSearch(searchQuery.value);
203
- internalOptions.value = result;
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
- onClickOutside(containerRef, closeDropdown);
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-chevron {
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: 100;
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.25rem 0;
369
+ padding: 0.5rem;
370
+ word-break: break-word;
371
+ pointer-events: none;
372
+ }
263
373
 
264
- > .octopus-form-item {
265
- padding: 0.25rem 0.5rem;
266
- border-bottom: 1px solid var(--octopus-secondary);
267
- }
374
+ .octopus-multiselect-chevron {
375
+ padding: 0.25rem 0.5rem;
376
+ display: flex;
377
+ align-items: center;
268
378
  }
269
379
 
270
- .octopus-multiselect-options {
271
- max-height: 14rem;
272
- overflow-y: auto;
380
+ }
273
381
 
274
- .octopus-form-item {
275
- padding: 0.25rem 0.5rem;
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>