itube-specs 0.0.968 → 0.0.969

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.
@@ -89,8 +89,10 @@ defineProps<{
89
89
  inset: 0;
90
90
  overflow-y: auto;
91
91
 
92
- // полоса прокрутки не должна наезжать на карточки
93
- padding-right: 4px;
92
+ /* Контейнер прокрутки обрезает всё, что элементы рисуют ЗА своей границей, —
93
+ кольцо аватара модели и рамки фокуса. Отступ в 2px даёт им место; справа
94
+ к нему добавлено расстояние до полосы прокрутки. */
95
+ padding: 2px 6px 2px 2px;
94
96
  }
95
97
  }
96
98
  </style>
@@ -8,6 +8,7 @@
8
8
  {'--center': center},
9
9
  {'--open': open},
10
10
  {'--separate': separateLastChild},
11
+ {'--escaped': escaped},
11
12
  ]"
12
13
  class="ui-dropdown"
13
14
  @mouseleave="mouseHandler(false)"
@@ -26,7 +27,10 @@
26
27
  <dialog
27
28
  v-if="useVIf ? open && $slots.items : true"
28
29
  ref="menuRef"
29
- :style="!useVIf ? { display: (open && $slots.items) ? '' : 'none' } : {}"
30
+ :style="[
31
+ !useVIf ? { display: (open && $slots.items) ? '' : 'none' } : {},
32
+ escapedStyle,
33
+ ]"
30
34
  :class="{'--open': open}"
31
35
  class="ui-dropdown__menu"
32
36
  @click="close"
@@ -92,9 +96,75 @@ const { registerOverlay, releaseOverlay } = useOverlayStack();
92
96
  const breakpoints = CSS_BREAKPOINTS;
93
97
  const { isTablet } = checkDeviceWidth(breakpoints);
94
98
 
99
+ /**
100
+ * Выпадашка внутри прокручиваемого блока (боковая колонка плеера, например)
101
+ * обрезается его `overflow` — меню уходит под край. Уводить его в `position: fixed`
102
+ * всегда не стали: у обычных выпадашек координаты считает браузер, и это надёжнее.
103
+ * Поэтому смотрим, есть ли над нами прокручиваемый предок, и только тогда считаем
104
+ * координаты сами. `overflow: hidden` не в счёт — там выпадашки и так живут.
105
+ */
106
+ function hasScrollableAncestor(element: HTMLElement | null): boolean {
107
+ let node = element?.parentElement || null;
108
+
109
+ while (node && node !== document.body) {
110
+ const { overflowX, overflowY } = getComputedStyle(node);
111
+
112
+ if (/auto|scroll/.test(`${overflowX} ${overflowY}`)) return true;
113
+
114
+ node = node.parentElement;
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ /** Меню выведено из потока и позиционируется от экрана. */
121
+ const escaped = ref(false);
122
+ const escapedStyle = ref<Record<string, string>>({});
123
+
124
+ /** Координаты меню от триггера: вниз, а если не влезает — вверх; прижимаем к экрану. */
125
+ function placeEscaped() {
126
+ const trigger = dropdownRef.value?.getBoundingClientRect();
127
+ const menu = menuRef.value;
128
+ if (!trigger || !menu) return;
129
+
130
+ const gap = 4;
131
+ const edge = 8;
132
+ const { offsetWidth: width, offsetHeight: height } = menu;
133
+
134
+ const below = trigger.bottom + gap;
135
+ const flip = below + height > window.innerHeight;
136
+ autoTop.value = flip;
137
+
138
+ let left = props.right ? trigger.right - width : trigger.left;
139
+ if (props.center) left = trigger.left + (trigger.width - width) / 2;
140
+
141
+ escapedStyle.value = {
142
+ top: `${Math.max(edge, flip ? trigger.top - height - gap : below)}px`,
143
+ left: `${Math.min(Math.max(edge, left), window.innerWidth - width - edge)}px`,
144
+ };
145
+ }
146
+
147
+ /** Прокрутка любого предка сдвигает триггер — пересчитываем, пока меню открыто. */
148
+ function onViewportChange() {
149
+ requestAnimationFrame(placeEscaped);
150
+ }
151
+
152
+ function bindViewportListeners(bind: boolean) {
153
+ const method = bind ? 'addEventListener' : 'removeEventListener';
154
+
155
+ window[method]('scroll', onViewportChange, true);
156
+ window[method]('resize', onViewportChange);
157
+ }
158
+
95
159
  function checkPosition() {
96
160
  if (!dropdownRef.value || !menuRef.value || isTablet.value) return;
97
161
  requestAnimationFrame(() => {
162
+ if (escaped.value) {
163
+ placeEscaped();
164
+
165
+ return;
166
+ }
167
+
98
168
  const triggerRect = dropdownRef.value.getBoundingClientRect();
99
169
  const menuHeight = menuRef.value.scrollHeight;
100
170
  const spaceBelow = window.innerHeight - triggerRect.bottom;
@@ -104,6 +174,12 @@ function checkPosition() {
104
174
 
105
175
  function openDropdown() {
106
176
  open.value = !open.value;
177
+
178
+ if (open.value && !isTablet.value) {
179
+ escaped.value = hasScrollableAncestor(dropdownRef.value);
180
+ if (escaped.value) bindViewportListeners(true);
181
+ }
182
+
107
183
  if (open.value) nextTick(() => checkPosition());
108
184
 
109
185
  if (isTablet.value && open.value) {
@@ -117,6 +193,8 @@ function openDropdown() {
117
193
 
118
194
  function close() {
119
195
  open.value = false;
196
+ bindViewportListeners(false);
197
+ escapedStyle.value = {};
120
198
 
121
199
  if (isTablet.value) {
122
200
  nextTick(() => {
@@ -139,6 +217,8 @@ watch(open, (val) => {
139
217
  });
140
218
 
141
219
  onBeforeUnmount(() => {
220
+ bindViewportListeners(false);
221
+
142
222
  if (open.value && isTablet.value) {
143
223
  scrollUnlock();
144
224
  releaseOverlay(menuRef.value);
@@ -278,6 +358,17 @@ defineExpose({ openDropdown, open });
278
358
  }
279
359
  }
280
360
 
361
+ /* Меню внутри прокручиваемого блока: позиционируется от экрана, координаты считает
362
+ скрипт (см. placeEscaped) — браузерные `bottom`/`transform` здесь только мешают. */
363
+ .ui-dropdown.--escaped .ui-dropdown__menu {
364
+ @include from-br(md) {
365
+ position: fixed;
366
+ right: auto;
367
+ bottom: auto;
368
+ transform: none;
369
+ }
370
+ }
371
+
281
372
  .ui-dropdown__menu-items > .ui-dropdown__item {
282
373
  @include font-md;
283
374
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.968",
4
+ "version": "0.0.969",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {