@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.
@@ -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-beta",
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": "^6.4.1",
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": "^5.2.4",
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: keyof T & string;
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
- return option[props.optionLabel] as string;
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-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;
@@ -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
- <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">
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
- v-for="(option, index) in displayedOptions"
72
- :key="index"
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="toggleOption(option)"
73
+ @update:text-init="toggleAll"
77
74
  />
78
- <span v-if="displayedOptions.length === 0" class="text-indic px-2">
79
- {{ t('No elements found. Consider changing the search query.') }}
80
- </span>
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
- </div>
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
- optionLabel: keyof T & string;
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 (displayedOptions.value.length === 0) {
213
+ if (visibleOptions.value.length === 0) {
146
214
  return false;
147
215
  }
148
- return displayedOptions.value.every((option) => isSelected(option));
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 = displayedOptions.value.filter((option: T) => !isSelected(option));
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
- ? !displayedOptions.value.some((opt) => opt[key] === item[key])
195
- : !displayedOptions.value.includes(item)
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 (!val) {
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
- .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;
451
+ }
359
452
 
360
- > .octopus-form-item {
361
- padding: 0.25rem 0.5rem;
362
- border-bottom: 1px solid var(--octopus-secondary);
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
- .octopus-multiselect-options {
367
- max-height: 14rem;
368
- overflow-y: auto;
469
+ .octopus-multiselect-options {
470
+ max-height: 14rem;
471
+ overflow-y: auto;
369
472
 
370
- .octopus-form-item {
371
- padding: 0.25rem 0.5rem;
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
- <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 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
- </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';
@@ -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
- .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
- }
269
+ }
212
270
 
213
- .octopus-select-options {
214
- max-height: 14rem;
215
- overflow-y: auto;
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
- .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;
282
+ .octopus-select-options {
283
+ max-height: 14rem;
284
+ overflow-y: auto;
285
+ }
226
286
 
227
- &:hover {
228
- background: var(--octopus-secondary-lighter);
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
- &.selected {
232
- font-weight: 600;
233
- color: var(--octopus-primary);
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>
@@ -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 {