@saooti/octopus-sdk 41.12.0-beta → 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 CHANGED
@@ -18,6 +18,7 @@
18
18
  - Ajout de nouvelles classes utilitaires dans le css
19
19
  - Ajout de nouvelles options d'apparence pour `ClassicButton`
20
20
  - Changement propriété principale de `ClassicButtonGroup` pour plus de cohérence
21
+ - Ajout de variables CSS pour configurer l'aspect de divers boutons
21
22
 
22
23
  **Fixes**
23
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saooti/octopus-sdk",
3
- "version": "41.12.0-beta",
3
+ "version": "41.12.0-beta2",
4
4
  "private": false,
5
5
  "description": "Javascript SDK for using octopus",
6
6
  "author": "Saooti",
@@ -1,5 +1,5 @@
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> {
@@ -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();
@@ -78,7 +83,7 @@ export function useOctopusDropdown<T>(
78
83
  }
79
84
  }
80
85
 
81
- onClickOutside(containerRef, closeDropdown);
86
+ onClickOutside(containerRef, closeDropdown, ignore?.length ? { ignore } : undefined);
82
87
 
83
88
  return {
84
89
  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-primary-less-transparent);
363
- border-radius: var(--octopus-border-radius);
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: white;
370
+ color: var(--octopus-btn-play-fg);
371
371
  background-color: transparent;
372
372
  border: 0;
373
373
  display: flex;
@@ -58,33 +58,40 @@
58
58
  {{ allLabelsText }}
59
59
  </div>
60
60
 
61
- <div v-if="isOpen" class="octopus-multiselect-dropdown">
62
- <ClassicCheckbox
63
- :text-init="allSelected"
64
- :label="selectAllText ?? t('All')"
65
- :is-disabled="isDisabled"
66
- @update:text-init="toggleAll"
67
- />
68
-
69
- <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
+ >
70
68
  <ClassicCheckbox
71
- v-for="(option, index) in displayedOptions"
72
- :key="index"
73
- :text-init="isSelected(option)"
74
- :label="getLabel(option)"
69
+ :text-init="allSelected"
70
+ :label="selectAllText ?? t('All')"
75
71
  :is-disabled="isDisabled"
76
- @update:text-init="toggleOption(option)"
72
+ @update:text-init="toggleAll"
77
73
  />
78
- <span v-if="displayedOptions.length === 0" class="text-indic px-2">
79
- {{ t('No elements found. Consider changing the search query.') }}
80
- </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>
81
88
  </div>
82
- </div>
89
+ </Teleport>
83
90
  </div>
84
91
  </template>
85
92
 
86
93
  <script setup lang="ts" generic="T">
87
- import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
94
+ import { type CSSProperties, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
88
95
  import { useI18n } from 'vue-i18n';
89
96
  import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue';
90
97
  import ClassicCheckbox from './ClassicCheckbox.vue';
@@ -123,6 +130,10 @@ const emit = defineEmits<{
123
130
 
124
131
  const { t } = useI18n();
125
132
 
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
+
126
137
  const {
127
138
  searchQuery,
128
139
  isOpen,
@@ -136,9 +147,23 @@ const {
136
147
  openDropdown,
137
148
  toggleDropdown,
138
149
  handleInput,
139
- } = useOctopusDropdown(props, (query) => emit('search', query), 'multiselect');
150
+ } = useOctopusDropdown(props, (query) => emit('search', query), 'multiselect', [dropdownRef]);
140
151
 
141
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
+ }
142
167
  const visibleCount = ref(2);
143
168
 
144
169
  const allSelected = computed(() => {
@@ -247,16 +272,23 @@ onMounted(() => {
247
272
  resizeObserver.observe(selectionRef.value);
248
273
  }
249
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);
250
278
  });
251
279
 
252
280
  onUnmounted(() => {
253
281
  resizeObserver?.disconnect();
282
+ window.removeEventListener('scroll', updateDropdownPosition, true);
283
+ window.removeEventListener('resize', updateDropdownPosition);
254
284
  });
255
285
 
256
286
  watch(() => props.selected, updateVisibleCount);
257
287
 
258
288
  watch(isOpen, (val) => {
259
- if (!val) {
289
+ if (val) {
290
+ nextTick(updateDropdownPosition);
291
+ } else {
260
292
  nextTick(updateVisibleCount);
261
293
  }
262
294
  });
@@ -345,31 +377,30 @@ watch(isOpen, (val) => {
345
377
  align-items: center;
346
378
  }
347
379
 
348
- .octopus-multiselect-dropdown {
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;
380
+ }
359
381
 
360
- > .octopus-form-item {
361
- padding: 0.25rem 0.5rem;
362
- border-bottom: 1px solid var(--octopus-secondary);
363
- }
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);
364
395
  }
396
+ }
365
397
 
366
- .octopus-multiselect-options {
367
- max-height: 14rem;
368
- overflow-y: auto;
398
+ .octopus-multiselect-options {
399
+ max-height: 14rem;
400
+ overflow-y: auto;
369
401
 
370
- .octopus-form-item {
371
- padding: 0.25rem 0.5rem;
372
- }
402
+ .octopus-form-item {
403
+ padding: 0.25rem 0.5rem;
373
404
  }
374
405
  }
375
406
  </style>
@@ -42,27 +42,34 @@
42
42
  </button>
43
43
  </div>
44
44
 
45
- <div v-if="isOpen" class="octopus-select-dropdown">
46
- <div class="octopus-select-options">
47
- <button
48
- v-for="(option, index) in displayedOptions"
49
- :key="index"
50
- class="octopus-select-option"
51
- :class="{ selected: isSelected(option) }"
52
- @click="selectOption(option)"
53
- >
54
- {{ getLabel(option) }}
55
- </button>
56
- <span v-if="displayedOptions.length === 0" class="text-indic px-2">
57
- {{ t('No elements found. Consider changing the search query.') }}
58
- </span>
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 displayedOptions"
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="displayedOptions.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
- </div>
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';
@@ -104,6 +111,10 @@ const emit = defineEmits<{
104
111
 
105
112
  const { t } = useI18n();
106
113
 
114
+ // Ref on the teleported dropdown div — passed as ignored element to useOctopusDropdown
115
+ // so clicks inside the dropdown don't trigger the click-outside handler.
116
+ const dropdownRef = ref<HTMLElement | null>(null);
117
+
107
118
  const {
108
119
  searchQuery,
109
120
  isOpen,
@@ -117,7 +128,38 @@ const {
117
128
  closeDropdown,
118
129
  toggleDropdown,
119
130
  handleInput,
120
- } = useOctopusDropdown(props, (query) => emit('search', query), 'select');
131
+ } = useOctopusDropdown(props, (query) => emit('search', query), 'select', [dropdownRef]);
132
+
133
+ // Position of the teleported dropdown (position: fixed, anchored below the trigger field)
134
+ const dropdownStyle = ref<CSSProperties>({});
135
+
136
+ function updateDropdownPosition(): void {
137
+ if (!containerRef.value) { return; }
138
+ const rect = containerRef.value.getBoundingClientRect();
139
+ dropdownStyle.value = {
140
+ position: 'fixed',
141
+ top: `${rect.bottom + 2}px`,
142
+ left: `${rect.left}px`,
143
+ width: `${rect.width}px`,
144
+ };
145
+ }
146
+
147
+ watch(isOpen, (val) => {
148
+ if (val) {
149
+ nextTick(updateDropdownPosition);
150
+ }
151
+ });
152
+
153
+ onMounted(() => {
154
+ // Keep the teleported dropdown aligned when the page scrolls or the viewport resizes
155
+ window.addEventListener('scroll', updateDropdownPosition, true);
156
+ window.addEventListener('resize', updateDropdownPosition);
157
+ });
158
+
159
+ onUnmounted(() => {
160
+ window.removeEventListener('scroll', updateDropdownPosition, true);
161
+ window.removeEventListener('resize', updateDropdownPosition);
162
+ });
121
163
 
122
164
  const selectedLabel = computed(() =>
123
165
  props.value !== undefined ? getLabel(props.value) : undefined
@@ -197,41 +239,40 @@ function selectOption(option: T): void {
197
239
  align-items: center;
198
240
  }
199
241
 
200
- .octopus-select-dropdown {
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
- }
242
+ }
212
243
 
213
- .octopus-select-options {
214
- max-height: 14rem;
215
- overflow-y: auto;
216
- }
244
+ // Dropdown is teleported to body — scoped rules must be top-level so that [data-v-xxxx]
245
+ // is matched directly on the element rather than via a descendant-of-.octopus-select selector.
246
+ .octopus-select-dropdown {
247
+ z-index: 100;
248
+ background: white;
249
+ border: 1px solid var(--octopus-border-default);
250
+ border-radius: var(--octopus-border-radius);
251
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
252
+ padding: 0.25rem 0;
253
+ }
217
254
 
218
- .octopus-select-option {
219
- display: block;
220
- width: 100%;
221
- text-align: left;
222
- padding: 0.25rem 0.5rem;
223
- border: none;
224
- background: transparent;
225
- cursor: pointer;
255
+ .octopus-select-options {
256
+ max-height: 14rem;
257
+ overflow-y: auto;
258
+ }
226
259
 
227
- &:hover {
228
- background: var(--octopus-secondary-lighter);
229
- }
260
+ .octopus-select-option {
261
+ display: block;
262
+ width: 100%;
263
+ text-align: left;
264
+ padding: 0.25rem 0.5rem;
265
+ border: none;
266
+ background: transparent;
267
+ cursor: pointer;
230
268
 
231
- &.selected {
232
- font-weight: 600;
233
- color: var(--octopus-primary);
234
- }
269
+ &:hover {
270
+ background: var(--octopus-secondary-lighter);
271
+ }
272
+
273
+ &.selected {
274
+ font-weight: 600;
275
+ color: var(--octopus-primary);
235
276
  }
236
277
  }
237
278
  </style>
@@ -110,6 +110,8 @@ function closePopup(): void {
110
110
 
111
111
  @media (width <= 500px) {
112
112
  width: 95vw;
113
+ margin-left: 2.5vw;
114
+ margin-right: 2.5vw;
113
115
  }
114
116
 
115
117
  .octopus-modal-body {
@@ -7,7 +7,7 @@
7
7
  'play-button-box': !isBigButton,
8
8
  'play-big-button-box': isBigButton,
9
9
  }"
10
- class="btn text-light bg-primary"
10
+ class="btn text-light"
11
11
  @click="switchPausePlay"
12
12
  >
13
13
  <PlayIcon v-if="displayIsPaused" :size="isBigButton ? 60 : 30" />
@@ -107,23 +107,27 @@ function switchPausePlay(): void {
107
107
  }
108
108
  </script>
109
109
 
110
- <style lang="scss">
110
+ <style scoped lang="scss">
111
111
  @use "../../../../style/playButton";
112
- .octopus-app {
113
- .play-button-box:not(.small-font) {
114
- font-size: 1rem !important;
115
- }
116
- .play-big-button-box {
117
- height: 5rem;
118
- width: 5rem;
119
- display: flex;
120
- align-items: center;
121
- justify-content: center;
122
- margin: 0 0.5rem;
123
- border-radius: 50% !important;
124
- font-size: 2.5rem !important;
125
- flex-shrink: 0;
126
- cursor: pointer;
127
- }
112
+
113
+ .btn {
114
+ color: var(--octopus-player-btn-fg);
115
+ background-color: var(--octopus-player-btn-bg);
116
+ }
117
+
118
+ .play-button-box:not(.small-font) {
119
+ font-size: 1rem !important;
120
+ }
121
+ .play-big-button-box {
122
+ height: 5rem;
123
+ width: 5rem;
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: center;
127
+ margin: 0 0.5rem;
128
+ border-radius: 50% !important;
129
+ font-size: 2.5rem !important;
130
+ flex-shrink: 0;
131
+ cursor: pointer;
128
132
  }
129
133
  </style>
@@ -48,6 +48,7 @@ export interface ParamStore {
48
48
  /** Show time with the dates on podcasts */
49
49
  showTimeWithDates?: boolean;
50
50
  buttonPlus?: boolean;
51
+ /** Show the "Radio & Live" tab in the navigation; driven by additionalConfiguration from the backend */
51
52
  isLiveTab?: boolean;
52
53
  isCaptchaTest?: boolean;
53
54
  podcastItem?: number;
@@ -24,6 +24,12 @@ export type OrganisationAttributes = {
24
24
  PRIVATE?: string;
25
25
  /** Live recordings enabled */
26
26
  'live.active'?: boolean;
27
+ /**
28
+ * List of rubriquage **names**.
29
+ * The rubriques from these rubriquage are stored in stats and can be queried
30
+ * upon.
31
+ */
32
+ rubriquage4MMIdentifier?: string;
27
33
  };
28
34
 
29
35
  export interface Organisation {
@@ -3,7 +3,7 @@ export interface Rubrique {
3
3
  name: string;
4
4
  podcastCount?: number;
5
5
  rubriquageId?: number;
6
- rubriqueId?: number;
6
+ rubriqueId: number;
7
7
  score?: number;
8
8
  organisationPrivacy?: string;
9
9
  }
@@ -43,6 +43,13 @@
43
43
  // ClassicDataTable
44
44
  --table-line-height: 48px;
45
45
 
46
+ // Buttons
47
+ --octopus-btn-primary-bg: var(--octopus-primary);
48
+ --octopus-btn-primary-fg: var(--octopus-color-on-primary);
49
+ --octopus-btn-play-bg: var(--octopus-primary-less-transparent);
50
+ --octopus-btn-play-fg: white;
51
+ --octopus-btn-play-radius: var(--octopus-border-radius);
52
+
46
53
  // Player
47
54
  // Color for the transcript background
48
55
  --octopus-player-transcript-bg-color: oklch(from var(--octopus-player-color) calc(l + 0.1) c h);
@@ -52,6 +59,10 @@
52
59
  --octopus-player-progress-color-current: var(--octopus-primary);
53
60
  // Color for the progress bar background
54
61
  --octopus-player-progress-background-color: var(--octopus-secondary-lighter);
62
+ // Color of the player's button
63
+ --octopus-player-btn-bg: var(--octopus-btn-primary-bg);
64
+ // Color of the player's button
65
+ --octopus-player-btn-fg: var(--octopus-btn-primary-fg);
55
66
 
56
67
  // Smartlink
57
68
  --octopus-smartlink-title-color: var(--octopus-gray-text);
@@ -153,10 +153,10 @@ input:not([class^="vs__"]), button:not([class^="vs__"]), select:not([class^="vs_
153
153
  display: flex;
154
154
  align-items: center;
155
155
  justify-content: center;
156
- background: var(--octopus-primary);
156
+ background: var(--octopus-btn-primary-bg);
157
157
  border: 1px solid var(--octopus-primary);
158
158
  border-radius: var(--octopus-border-radius) !important;
159
- color: var(--octopus-color-on-primary) !important;
159
+ color: var(--octopus-btn-primary-fg) !important;
160
160
  font-weight: 500;
161
161
 
162
162
  &:not(.btn-on-dark):is(:focus, :hover, :active, .active){
@@ -1,8 +1,9 @@
1
1
  import '@tests/mocks/i18n';
2
2
 
3
3
  import OctopusMultiselect from '@/components/form/OctopusMultiselect.vue';
4
+ import { DOMWrapper, type VueWrapper } from '@vue/test-utils';
4
5
  import { mount } from '@tests/utils';
5
- import { describe, expect, it, vi } from 'vitest';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
7
 
7
8
  const options = [
8
9
  { id: 1, name: 'Alpha' },
@@ -10,66 +11,89 @@ const options = [
10
11
  { id: 3, name: 'Gamma' },
11
12
  ];
12
13
 
14
+ // The dropdown is teleported to .octopus-app — helper to query it from the document.
15
+ function getDropdown(): Element | null {
16
+ return document.body.querySelector('.octopus-multiselect-dropdown');
17
+ }
18
+
13
19
  describe('OctopusMultiselect', () => {
20
+ // The dropdown teleports to .octopus-app, which is normally the app root (see App.vue).
21
+ // It doesn't exist in the test DOM, so it must be created before mount.
22
+ let appElement: HTMLElement;
23
+
24
+ beforeEach(() => {
25
+ appElement = document.createElement('div');
26
+ appElement.className = 'octopus-app';
27
+ document.body.appendChild(appElement);
28
+ });
29
+
30
+ // Shared wrapper ref so afterEach can properly unmount it.
31
+ // Proper unmount lets Vue clean up the teleport target before the next test starts.
32
+ let wrapper: VueWrapper;
33
+ afterEach(() => {
34
+ wrapper?.unmount();
35
+ appElement.remove();
36
+ });
37
+
14
38
  it('renders label when provided', async () => {
15
- const wrapper = await mount(OctopusMultiselect, {
39
+ wrapper = await mount(OctopusMultiselect, {
16
40
  props: { options, optionLabel: 'name', label: 'My label' },
17
41
  });
18
42
  expect(wrapper.find('label.form-label').text()).toBe('My label');
19
43
  });
20
44
 
21
45
  it('does not render label when not provided', async () => {
22
- const wrapper = await mount(OctopusMultiselect, {
46
+ wrapper = await mount(OctopusMultiselect, {
23
47
  props: { options, optionLabel: 'name' },
24
48
  });
25
49
  expect(wrapper.find('label.form-label').exists()).toBe(false);
26
50
  });
27
51
 
28
52
  it('opens dropdown on input focus', async () => {
29
- const wrapper = await mount(OctopusMultiselect, {
53
+ wrapper = await mount(OctopusMultiselect, {
30
54
  props: { options, optionLabel: 'name' },
31
55
  });
32
- expect(wrapper.find('.octopus-multiselect-dropdown').exists()).toBe(false);
56
+ expect(getDropdown()).toBeNull();
33
57
  await wrapper.find('input').trigger('focus');
34
- expect(wrapper.find('.octopus-multiselect-dropdown').exists()).toBe(true);
58
+ expect(getDropdown()).not.toBeNull();
35
59
  });
36
60
 
37
61
  it('shows "All" checkbox and one checkbox per option when open', async () => {
38
- const wrapper = await mount(OctopusMultiselect, {
62
+ wrapper = await mount(OctopusMultiselect, {
39
63
  props: { options, optionLabel: 'name' },
40
64
  });
41
65
  await wrapper.find('input').trigger('focus');
42
- const checkboxes = wrapper.findAll('input[type="checkbox"]');
66
+ const checkboxes = document.body.querySelectorAll('input[type="checkbox"]');
43
67
  // 1 for "All" + 3 for options
44
68
  expect(checkboxes).toHaveLength(4);
45
69
  });
46
70
 
47
71
  it('filters options locally by search query', async () => {
48
- const wrapper = await mount(OctopusMultiselect, {
72
+ wrapper = await mount(OctopusMultiselect, {
49
73
  props: { options, optionLabel: 'name' },
50
74
  });
51
75
  await wrapper.find('input').trigger('focus');
52
76
  await wrapper.find('input').setValue('al');
53
77
  await wrapper.find('input').trigger('input');
54
- const checkboxes = wrapper.findAll('input[type="checkbox"]');
78
+ const checkboxes = document.body.querySelectorAll('input[type="checkbox"]');
55
79
  // 1 for "All" + 1 matching "Alpha"
56
80
  expect(checkboxes).toHaveLength(2);
57
81
  });
58
82
 
59
83
  it('shows no-results message when filter matches nothing', async () => {
60
- const wrapper = await mount(OctopusMultiselect, {
84
+ wrapper = await mount(OctopusMultiselect, {
61
85
  props: { options, optionLabel: 'name' },
62
86
  });
63
87
  await wrapper.find('input').trigger('focus');
64
88
  await wrapper.find('input').setValue('zzz');
65
89
  await wrapper.find('input').trigger('input');
66
- expect(wrapper.find('.text-indic').exists()).toBe(true);
90
+ expect(document.body.querySelector('.text-indic')).not.toBeNull();
67
91
  });
68
92
 
69
93
  it('calls onSearch with current query and updates displayed options', async () => {
70
94
  const searchResult = [{ id: 4, name: 'Delta' }];
71
95
  const onSearch = vi.fn().mockResolvedValue(searchResult);
72
- const wrapper = await mount(OctopusMultiselect, {
96
+ wrapper = await mount(OctopusMultiselect, {
73
97
  props: { options, optionLabel: 'name', onSearch },
74
98
  });
75
99
  await wrapper.find('input').trigger('focus');
@@ -80,48 +104,48 @@ describe('OctopusMultiselect', () => {
80
104
  });
81
105
 
82
106
  it('emits update:selected when toggling a single option', async () => {
83
- const wrapper = await mount(OctopusMultiselect, {
107
+ wrapper = await mount(OctopusMultiselect, {
84
108
  props: { options, optionLabel: 'name', selected: [] },
85
109
  });
86
110
  await wrapper.find('input').trigger('focus');
87
- const optionCheckboxes = wrapper.findAll('.octopus-multiselect-options input[type="checkbox"]');
88
- await optionCheckboxes[0].trigger('input');
111
+ const optionCheckboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
112
+ await new DOMWrapper(optionCheckboxes[0] as Element).trigger('input');
89
113
  expect(wrapper.emitted('update:selected')?.[0]).toEqual([[options[0]]]);
90
114
  });
91
115
 
92
116
  it('deselects an already-selected option', async () => {
93
- const wrapper = await mount(OctopusMultiselect, {
117
+ wrapper = await mount(OctopusMultiselect, {
94
118
  props: { options, optionLabel: 'name', selected: [options[0]] },
95
119
  });
96
120
  await wrapper.find('input').trigger('focus');
97
- const optionCheckboxes = wrapper.findAll('.octopus-multiselect-options input[type="checkbox"]');
98
- await optionCheckboxes[0].trigger('input');
121
+ const optionCheckboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
122
+ await new DOMWrapper(optionCheckboxes[0] as Element).trigger('input');
99
123
  expect(wrapper.emitted('update:selected')?.[0]).toEqual([[]]);
100
124
  });
101
125
 
102
126
  it('toggleAll selects all displayed options', async () => {
103
- const wrapper = await mount(OctopusMultiselect, {
127
+ wrapper = await mount(OctopusMultiselect, {
104
128
  props: { options, optionLabel: 'name', selected: [] },
105
129
  });
106
130
  await wrapper.find('input').trigger('focus');
107
- const allCheckbox = wrapper.find('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]');
108
- await allCheckbox.trigger('input');
131
+ const allCheckbox = document.body.querySelector('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]')!;
132
+ await new DOMWrapper(allCheckbox).trigger('input');
109
133
  const emitted = wrapper.emitted('update:selected')?.[0]?.[0] as unknown[];
110
134
  expect(emitted).toHaveLength(3);
111
135
  });
112
136
 
113
137
  it('toggleAll deselects all displayed options when all are selected', async () => {
114
- const wrapper = await mount(OctopusMultiselect, {
138
+ wrapper = await mount(OctopusMultiselect, {
115
139
  props: { options, optionLabel: 'name', selected: [...options] },
116
140
  });
117
141
  await wrapper.find('input').trigger('focus');
118
- const allCheckbox = wrapper.find('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]');
119
- await allCheckbox.trigger('input');
142
+ const allCheckbox = document.body.querySelector('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]')!;
143
+ await new DOMWrapper(allCheckbox).trigger('input');
120
144
  expect(wrapper.emitted('update:selected')?.[0]).toEqual([[]]);
121
145
  });
122
146
 
123
147
  it('shows selected items in the selection display', async () => {
124
- const wrapper = await mount(OctopusMultiselect, {
148
+ wrapper = await mount(OctopusMultiselect, {
125
149
  props: { options, optionLabel: 'name', selected: [options[0], options[1]] },
126
150
  });
127
151
  expect(wrapper.find('.octopus-multiselect-selection-text').text()).toBe('Alpha, Beta');
@@ -129,55 +153,55 @@ describe('OctopusMultiselect', () => {
129
153
  });
130
154
 
131
155
  it('shows overflow count badge when more than visible items are selected', async () => {
132
- const wrapper = await mount(OctopusMultiselect, {
156
+ wrapper = await mount(OctopusMultiselect, {
133
157
  props: { options, optionLabel: 'name', selected: [...options] },
134
158
  });
135
159
  expect(wrapper.find('.octopus-multiselect-selection-count').exists()).toBe(true);
136
160
  });
137
161
 
138
162
  it('disables input when isDisabled is true', async () => {
139
- const wrapper = await mount(OctopusMultiselect, {
163
+ wrapper = await mount(OctopusMultiselect, {
140
164
  props: { options, optionLabel: 'name', isDisabled: true },
141
165
  });
142
166
  expect(wrapper.find('input').attributes('disabled')).toBeDefined();
143
167
  });
144
168
 
145
169
  it('does not open dropdown when disabled', async () => {
146
- const wrapper = await mount(OctopusMultiselect, {
170
+ wrapper = await mount(OctopusMultiselect, {
147
171
  props: { options, optionLabel: 'name', isDisabled: true },
148
172
  });
149
173
  await wrapper.find('input').trigger('focus');
150
- expect(wrapper.find('.octopus-multiselect-dropdown').exists()).toBe(false);
174
+ expect(getDropdown()).toBeNull();
151
175
  });
152
176
 
153
177
  it('uses selectAllText as the "All" checkbox label when provided', async () => {
154
- const wrapper = await mount(OctopusMultiselect, {
178
+ wrapper = await mount(OctopusMultiselect, {
155
179
  props: { options, optionLabel: 'name', selectAllText: 'Tout' },
156
180
  });
157
181
  await wrapper.find('input').trigger('focus');
158
- const allCheckboxLabel = wrapper.find('.octopus-multiselect-dropdown > .octopus-form-item label');
159
- expect(allCheckboxLabel.text()).toBe('Tout');
182
+ const allCheckboxLabel = document.body.querySelector('.octopus-multiselect-dropdown > .octopus-form-item label')!;
183
+ expect(allCheckboxLabel.textContent?.trim()).toBe('Tout');
160
184
  });
161
185
 
162
186
  it('deselects by optionKey even when selected objects are different references', async () => {
163
187
  const selected = [{ id: 1, name: 'Alpha' }];
164
- const wrapper = await mount(OctopusMultiselect, {
188
+ wrapper = await mount(OctopusMultiselect, {
165
189
  props: { options, optionLabel: 'name', optionKey: 'id', selected },
166
190
  });
167
191
  await wrapper.find('input').trigger('focus');
168
- const optionCheckboxes = wrapper.findAll('.octopus-multiselect-options input[type="checkbox"]');
169
- await optionCheckboxes[0].trigger('input');
192
+ const optionCheckboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
193
+ await new DOMWrapper(optionCheckboxes[0] as Element).trigger('input');
170
194
  expect(wrapper.emitted('update:selected')?.[0]).toEqual([[]]);
171
195
  });
172
196
 
173
197
  it('toggleAll deselects by optionKey even when selected objects are different references', async () => {
174
198
  const selected = options.map((o) => ({ ...o }));
175
- const wrapper = await mount(OctopusMultiselect, {
199
+ wrapper = await mount(OctopusMultiselect, {
176
200
  props: { options, optionLabel: 'name', optionKey: 'id', selected },
177
201
  });
178
202
  await wrapper.find('input').trigger('focus');
179
- const allCheckbox = wrapper.find('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]');
180
- await allCheckbox.trigger('input');
203
+ const allCheckbox = document.body.querySelector('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]')!;
204
+ await new DOMWrapper(allCheckbox).trigger('input');
181
205
  expect(wrapper.emitted('update:selected')?.[0]).toEqual([[]]);
182
206
  });
183
207
  });
@@ -1,8 +1,9 @@
1
1
  import '@tests/mocks/i18n';
2
2
 
3
3
  import OctopusSelect from '@/components/form/OctopusSelect.vue';
4
+ import { DOMWrapper, type VueWrapper } from '@vue/test-utils';
4
5
  import { mount } from '@tests/utils';
5
- import { describe, expect, it } from 'vitest';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
7
 
7
8
  const options = [
8
9
  { id: 1, name: 'Alpha' },
@@ -10,123 +11,146 @@ const options = [
10
11
  { id: 3, name: 'Gamma' },
11
12
  ];
12
13
 
14
+ // The dropdown is teleported to .octopus-app — helper to query it from the document.
15
+ function getDropdown(): Element | null {
16
+ return document.body.querySelector('.octopus-select-dropdown');
17
+ }
18
+
13
19
  describe('OctopusSelect', () => {
20
+ // The dropdown teleports to .octopus-app, which is normally the app root (see App.vue).
21
+ // It doesn't exist in the test DOM, so it must be created before mount.
22
+ let appElement: HTMLElement;
23
+
24
+ beforeEach(() => {
25
+ appElement = document.createElement('div');
26
+ appElement.className = 'octopus-app';
27
+ document.body.appendChild(appElement);
28
+ });
29
+
30
+ // Shared wrapper ref so afterEach can properly unmount it.
31
+ // Proper unmount lets Vue clean up the teleport target before the next test starts.
32
+ let wrapper: VueWrapper;
33
+ afterEach(() => {
34
+ wrapper?.unmount();
35
+ appElement.remove();
36
+ });
37
+
14
38
  it('renders label when provided', async () => {
15
- const wrapper = await mount(OctopusSelect, {
39
+ wrapper = await mount(OctopusSelect, {
16
40
  props: { options, optionLabel: 'name', label: 'My label' },
17
41
  });
18
42
  expect(wrapper.find('label.form-label').text()).toBe('My label');
19
43
  });
20
44
 
21
45
  it('does not render label when not provided', async () => {
22
- const wrapper = await mount(OctopusSelect, {
46
+ wrapper = await mount(OctopusSelect, {
23
47
  props: { options, optionLabel: 'name' },
24
48
  });
25
49
  expect(wrapper.find('label.form-label').exists()).toBe(false);
26
50
  });
27
51
 
28
52
  it('opens dropdown on input focus', async () => {
29
- const wrapper = await mount(OctopusSelect, {
53
+ wrapper = await mount(OctopusSelect, {
30
54
  props: { options, optionLabel: 'name' },
31
55
  });
32
- expect(wrapper.find('.octopus-select-dropdown').exists()).toBe(false);
56
+ expect(getDropdown()).toBeNull();
33
57
  await wrapper.find('input').trigger('focus');
34
- expect(wrapper.find('.octopus-select-dropdown').exists()).toBe(true);
58
+ expect(getDropdown()).not.toBeNull();
35
59
  });
36
60
 
37
61
  it('shows one button per option when open', async () => {
38
- const wrapper = await mount(OctopusSelect, {
62
+ wrapper = await mount(OctopusSelect, {
39
63
  props: { options, optionLabel: 'name' },
40
64
  });
41
65
  await wrapper.find('input').trigger('focus');
42
- const optionButtons = wrapper.findAll('.octopus-select-option');
66
+ const optionButtons = document.body.querySelectorAll('.octopus-select-option');
43
67
  expect(optionButtons).toHaveLength(3);
44
- expect(optionButtons[0].text()).toBe('Alpha');
68
+ expect(optionButtons[0].textContent?.trim()).toBe('Alpha');
45
69
  });
46
70
 
47
71
  it('filters options locally by search query', async () => {
48
- const wrapper = await mount(OctopusSelect, {
72
+ wrapper = await mount(OctopusSelect, {
49
73
  props: { options, optionLabel: 'name' },
50
74
  });
51
75
  await wrapper.find('input').trigger('focus');
52
76
  await wrapper.find('input').setValue('al');
53
77
  await wrapper.find('input').trigger('input');
54
- expect(wrapper.findAll('.octopus-select-option')).toHaveLength(1);
78
+ expect(document.body.querySelectorAll('.octopus-select-option')).toHaveLength(1);
55
79
  });
56
80
 
57
81
  it('shows no-results message when filter matches nothing', async () => {
58
- const wrapper = await mount(OctopusSelect, {
82
+ wrapper = await mount(OctopusSelect, {
59
83
  props: { options, optionLabel: 'name' },
60
84
  });
61
85
  await wrapper.find('input').trigger('focus');
62
86
  await wrapper.find('input').setValue('zzz');
63
87
  await wrapper.find('input').trigger('input');
64
- expect(wrapper.find('.text-indic').exists()).toBe(true);
88
+ expect(document.body.querySelector('.text-indic')).not.toBeNull();
65
89
  });
66
90
 
67
91
  it('emits update:value with the clicked option', async () => {
68
- const wrapper = await mount(OctopusSelect, {
92
+ wrapper = await mount(OctopusSelect, {
69
93
  props: { options, optionLabel: 'name' },
70
94
  });
71
95
  await wrapper.find('input').trigger('focus');
72
- await wrapper.findAll('.octopus-select-option')[1].trigger('click');
96
+ await new DOMWrapper(document.body.querySelectorAll('.octopus-select-option')[1]).trigger('click');
73
97
  expect(wrapper.emitted('update:value')?.[0]).toEqual([options[1]]);
74
98
  });
75
99
 
76
100
  it('closes dropdown after selecting an option', async () => {
77
- const wrapper = await mount(OctopusSelect, {
101
+ wrapper = await mount(OctopusSelect, {
78
102
  props: { options, optionLabel: 'name' },
79
103
  });
80
104
  await wrapper.find('input').trigger('focus');
81
- await wrapper.findAll('.octopus-select-option')[0].trigger('click');
82
- expect(wrapper.find('.octopus-select-dropdown').exists()).toBe(false);
105
+ await new DOMWrapper(document.body.querySelectorAll('.octopus-select-option')[0]).trigger('click');
106
+ expect(getDropdown()).toBeNull();
83
107
  });
84
108
 
85
109
  it('shows selected item label in the field when closed', async () => {
86
- const wrapper = await mount(OctopusSelect, {
110
+ wrapper = await mount(OctopusSelect, {
87
111
  props: { options, optionLabel: 'name', value: options[0] },
88
112
  });
89
113
  expect(wrapper.find('.octopus-select-value').text()).toBe('Alpha');
90
114
  });
91
115
 
92
116
  it('marks the selected option with the selected class', async () => {
93
- const wrapper = await mount(OctopusSelect, {
117
+ wrapper = await mount(OctopusSelect, {
94
118
  props: { options, optionLabel: 'name', value: options[0] },
95
119
  });
96
120
  await wrapper.find('.octopus-select-field').trigger('click');
97
- const optionButtons = wrapper.findAll('.octopus-select-option');
98
- expect(optionButtons[0].classes()).toContain('selected');
99
- expect(optionButtons[1].classes()).not.toContain('selected');
121
+ const optionButtons = document.body.querySelectorAll('.octopus-select-option');
122
+ expect(optionButtons[0].classList).toContain('selected');
123
+ expect(optionButtons[1].classList).not.toContain('selected');
100
124
  });
101
125
 
102
126
  it('deselects the current option by default (allowDeselect defaults to true)', async () => {
103
- const wrapper = await mount(OctopusSelect, {
127
+ wrapper = await mount(OctopusSelect, {
104
128
  props: { options, optionLabel: 'name', optionKey: 'id', value: options[0] },
105
129
  });
106
130
  await wrapper.find('.octopus-select-field').trigger('click');
107
- await wrapper.findAll('.octopus-select-option')[0].trigger('click');
131
+ await new DOMWrapper(document.body.querySelectorAll('.octopus-select-option')[0]).trigger('click');
108
132
  expect(wrapper.emitted('update:value')?.[0]).toEqual([undefined]);
109
133
  });
110
134
 
111
135
  it('does not deselect when allowDeselect is false', async () => {
112
- const wrapper = await mount(OctopusSelect, {
136
+ wrapper = await mount(OctopusSelect, {
113
137
  props: { options, optionLabel: 'name', optionKey: 'id', value: options[0], allowDeselect: false as boolean },
114
138
  });
115
139
  await wrapper.find('.octopus-select-field').trigger('click');
116
- await wrapper.findAll('.octopus-select-option')[0].trigger('click');
140
+ await new DOMWrapper(document.body.querySelectorAll('.octopus-select-option')[0]).trigger('click');
117
141
  expect(wrapper.emitted('update:value')?.[0]).toEqual([options[0]]);
118
142
  });
119
143
 
120
144
  it('does not open dropdown when disabled', async () => {
121
- const wrapper = await mount(OctopusSelect, {
145
+ wrapper = await mount(OctopusSelect, {
122
146
  props: { options, optionLabel: 'name', isDisabled: true },
123
147
  });
124
148
  await wrapper.find('input').trigger('focus');
125
- expect(wrapper.find('.octopus-select-dropdown').exists()).toBe(false);
149
+ expect(getDropdown()).toBeNull();
126
150
  });
127
151
 
128
152
  it('disables input when isDisabled is true', async () => {
129
- const wrapper = await mount(OctopusSelect, {
153
+ wrapper = await mount(OctopusSelect, {
130
154
  props: { options, optionLabel: 'name', isDisabled: true },
131
155
  });
132
156
  expect(wrapper.find('input').attributes('disabled')).toBeDefined();
@@ -134,10 +158,11 @@ describe('OctopusSelect', () => {
134
158
 
135
159
  it('matches by optionKey when checking selection', async () => {
136
160
  const value = { id: 1, name: 'Alpha' };
137
- const wrapper = await mount(OctopusSelect, {
161
+ wrapper = await mount(OctopusSelect, {
138
162
  props: { options, optionLabel: 'name', optionKey: 'id', value },
139
163
  });
140
164
  await wrapper.find('.octopus-select-field').trigger('click');
141
- expect(wrapper.findAll('.octopus-select-option')[0].classes()).toContain('selected');
165
+ const optionButtons = document.body.querySelectorAll('.octopus-select-option');
166
+ expect(optionButtons[0].classList).toContain('selected');
142
167
  });
143
168
  });