itube-specs 0.0.935 → 0.0.936

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.
@@ -22,7 +22,8 @@
22
22
  :aria-pressed="later"
23
23
  :class="['card-video-actions__btn', '--watch-later', { '--active': later }]"
24
24
  type="button"
25
- @click="toggleWatchLater(card.guid, card.title)"
25
+ v-bind="hoverGuard"
26
+ @click="onWatchLaterClick"
26
27
  >
27
28
  <UiIcon
28
29
  :name="later ? 'clock-filled' : 'clock'"
@@ -44,9 +45,17 @@ const props = defineProps<{
44
45
  const { t } = useI18n();
45
46
 
46
47
  const { isFavorite, isWatchLater, toggleFavorite, toggleWatchLater } = useVideoCollections();
48
+ const { blockHover, hoverGuard } = useHoverGuard();
47
49
 
48
50
  const favorite = computed(() => isFavorite(props.card.guid));
49
51
  const later = computed(() => isWatchLater(props.card.guid));
52
+
53
+ /* The heart is only rendered while the video is in favorites, so a click always unmounts it — only
54
+ «watch later» stays under the cursor and needs the hover muted. */
55
+ function onWatchLaterClick() {
56
+ blockHover();
57
+ toggleWatchLater(props.card.guid, props.card.title);
58
+ }
50
59
  </script>
51
60
 
52
61
  <style lang="scss">
@@ -104,11 +113,11 @@ $action-size: 36px;
104
113
  opacity: 1;
105
114
  }
106
115
 
107
- .card-video-actions__btn:is(:hover, :focus-visible) {
116
+ .card-video-actions__btn:not(.--active):is(:hover, :focus-visible) {
108
117
  background-color: $video-card-action-bg--hover;
109
118
  }
110
119
 
111
- .card-video-actions__btn.--active:is(:hover, :focus-visible) {
120
+ .card-video-actions__btn.--active:not(.--no-hover):is(:hover, :focus-visible) {
112
121
  background-color: $video-card-action-active-bg--hover;
113
122
  border-color: $video-card-action-active-border--hover;
114
123
  color: $video-card-action-active-icon--hover;
@@ -107,7 +107,6 @@
107
107
 
108
108
  <script setup lang="ts">
109
109
  import type { IModelFilter } from '../../types';
110
- import { onClickOutside } from '@vueuse/core';
111
110
  import { convertString, checkDeviceWidth } from '../../runtime';
112
111
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
113
112
 
@@ -178,19 +177,24 @@ function toggleSlider(name: string) {
178
177
  }
179
178
  }
180
179
 
181
- // Следим за активным слайдером и навешиваем onClickOutside
182
- watch(activeSliderName, (newName) => {
183
- if (!newName) return;
184
- const index = mainFilters.value.findIndex(f => f.name === newName);
185
- if (index !== -1 && sliderRefs.value[ index ]) {
186
- const cleanup = onClickOutside(sliderRefs.value[ index ]!, () => {
187
- activeSliderName.value = null;
188
- cleanup();
189
- });
190
- }
180
+ const { isMobile } = checkDeviceWidth(CSS_BREAKPOINTS);
181
+
182
+ // Слайдер на строке один открытый из многих — «мимо» считаем от корня именно активного.
183
+ // На мобиле слайдер живёт в UiPopup, тот закрывается сам, и сбросить надо только имя.
184
+ const activeSliderEl = computed(() => {
185
+ if (!activeSliderName.value) return null;
186
+
187
+ const index = mainFilters.value.findIndex(f => f.name === activeSliderName.value);
188
+ return index === -1 ? null : sliderRefs.value[ index ];
191
189
  });
192
190
 
193
- const { isMobile } = checkDeviceWidth(CSS_BREAKPOINTS);
191
+ useOverlayClose(activeSliderEl, () => !!activeSliderName.value && !isMobile.value, () => {
192
+ activeSliderName.value = null;
193
+ });
194
+
195
+ watch(popupShow, (value) => {
196
+ if (!value) activeSliderName.value = null;
197
+ });
194
198
 
195
199
  const sliderEdgeClass = ref<Record<number, string>>({});
196
200
 
@@ -4,6 +4,7 @@
4
4
  :class="{'layout-burger--hide': isAuthPopupOpen}"
5
5
  class="layout-burger"
6
6
  @click="closeByBackdropClick"
7
+ @cancel.prevent
7
8
  >
8
9
  <button
9
10
  :aria-label="$t('close')"
@@ -144,6 +145,10 @@ function close() {
144
145
  emit('update:modelValue', false);
145
146
  }
146
147
 
148
+ // Диалог висит в разметке всегда, поэтому в стеке — только пока открыт. Нативный `cancel` погашен:
149
+ // браузер закрыл бы его мимо `modelValue`, оставив заблокированный скролл и съев одно нажатие кнопки.
150
+ useEscapeClose(() => props.modelValue, close);
151
+
147
152
  function closeByBackdropClick(event: Event) {
148
153
  const target = event.target as HTMLDialogElement;
149
154
  onBackdropClick(target, close);
@@ -32,14 +32,12 @@
32
32
  </template>
33
33
 
34
34
  <script setup lang="ts">
35
- import { onClickOutside } from '@vueuse/core';
36
-
37
35
  const { locale } = useI18n();
38
36
 
39
37
  const menuRef = ref();
40
38
  const isMenuShow = ref(false);
41
39
 
42
- onClickOutside(menuRef, () => {
40
+ useOverlayClose(menuRef, isMenuShow, () => {
43
41
  isMenuShow.value = false;
44
42
  });
45
43
 
@@ -107,7 +107,6 @@
107
107
  </template>
108
108
 
109
109
  <script setup lang="ts">
110
- import { onClickOutside } from '@vueuse/core';
111
110
  import { AuthorizationApiService } from '../../services/api/authorization.service';
112
111
  import { profileNavigationItems } from '../../lib';
113
112
  import type { IProfileData } from '../../types';
@@ -118,7 +117,7 @@ const isChildsShow = ref(false);
118
117
 
119
118
  const data = computed(() => useState<IProfileData>('profileData').value);
120
119
 
121
- onClickOutside(userRef, () => {
120
+ useOverlayClose(userRef, isMenuShow, () => {
122
121
  isMenuShow.value = false;
123
122
  });
124
123
 
@@ -88,7 +88,6 @@
88
88
 
89
89
  <script setup lang="ts">
90
90
  import { AuthSteps } from '../../runtime';
91
- import { onClickOutside } from '@vueuse/core';
92
91
  import { AuthorizationApiService } from '../../services/api/authorization.service';
93
92
 
94
93
  const { locale } = useI18n();
@@ -101,9 +100,7 @@ const { isAuthorized } = useUser(AuthorizationApiService);
101
100
 
102
101
  const { openAuthPopup } = useAuthPopup();
103
102
 
104
- onClickOutside(languageRef, () => {
105
- showLanguage.value = false;
106
- });
103
+ useOverlayClose(languageRef, showLanguage, closeLanguage);
107
104
 
108
105
  function showLanguageMenu() {
109
106
  showLanguage.value = !showLanguage.value;
@@ -3,7 +3,6 @@
3
3
  v-if="niches.length > 1"
4
4
  ref="nicheRef"
5
5
  class="layout-niche"
6
- @keydown.esc="isOpen = false"
7
6
  >
8
7
  <button
9
8
  :class="{'--open': isOpen}"
@@ -56,7 +55,6 @@
56
55
  </template>
57
56
 
58
57
  <script setup lang="ts">
59
- import { onClickOutside } from '@vueuse/core';
60
58
  import type { Niche } from '../../runtime';
61
59
 
62
60
  withDefaults(defineProps<{
@@ -71,7 +69,7 @@ const { niches, currentNiche, changeNiche } = useNiches();
71
69
  const nicheRef = ref<HTMLElement>();
72
70
  const isOpen = ref(false);
73
71
 
74
- onClickOutside(nicheRef, () => {
72
+ useOverlayClose(nicheRef, isOpen, () => {
75
73
  isOpen.value = false;
76
74
  });
77
75
 
@@ -51,8 +51,6 @@
51
51
  </template>
52
52
 
53
53
  <script setup lang="ts">
54
- import { onClickOutside } from '@vueuse/core';
55
-
56
54
  const searchValue = useState('searchValue', () => '');
57
55
 
58
56
  const searchSlug = useSlug();
@@ -76,10 +74,13 @@ watch(() => route.path, (value) => {
76
74
  }
77
75
  }, { immediate: true });
78
76
 
79
- onClickOutside(searchRef, () => {
77
+ useOverlayClose(searchRef, isResultShow, () => {
80
78
  isResultShow.value = false;
81
79
  });
82
80
 
81
+ // Мобильный оверлей поиска — свой слой: Esc сначала гасит подсказки, потом сам оверлей
82
+ useEscapeClose(() => searchShow.value && !isResultShow.value, onCloseClick);
83
+
83
84
  watch(isResultShow, (value) => {
84
85
  if (value) {
85
86
  scrollLock();
@@ -52,7 +52,6 @@
52
52
  </template>
53
53
 
54
54
  <script setup lang="ts">
55
- import { onClickOutside } from '@vueuse/core';
56
55
  import { convertString } from '../../runtime';
57
56
 
58
57
  const props = withDefaults(defineProps<{
@@ -157,7 +156,7 @@ function measure() {
157
156
 
158
157
  useChipsOverflow(listRef, measure);
159
158
 
160
- onClickOutside(rootRef, () => {
159
+ useOverlayClose(rootRef, open, () => {
161
160
  open.value = false;
162
161
  });
163
162
  </script>
@@ -30,6 +30,7 @@
30
30
  :class="{'--open': open}"
31
31
  class="ui-dropdown__menu"
32
32
  @click="close"
33
+ @cancel.prevent
33
34
  @mouseenter="mouseHandler(true)"
34
35
  @mouseleave="mouseHandler(false)"
35
36
  >
@@ -63,7 +64,6 @@
63
64
  // На компоненте обязательно вызывать пропсы слота и вешать слот пропс (className) на непосредственных детей
64
65
  import { checkDeviceWidth } from '../../runtime';
65
66
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
66
- import { onClickOutside } from '@vueuse/core';
67
67
 
68
68
  const dropdownRef = ref();
69
69
  const menuRef = ref();
@@ -83,9 +83,7 @@ const props = withDefaults(defineProps<{
83
83
  const open = ref(false);
84
84
  const autoTop = ref(false);
85
85
 
86
- onClickOutside(dropdownRef, () => {
87
- open.value = false;
88
- });
86
+ useOverlayClose(dropdownRef, open, () => close());
89
87
 
90
88
  const { scrollLock, scrollUnlock } = useGlobalScrollLock();
91
89
 
@@ -5,6 +5,7 @@
5
5
  :class="[`--${size}`, { '--following': following }]"
6
6
  class="ui-follow-btn"
7
7
  type="button"
8
+ v-bind="hoverGuard"
8
9
  @click="onClick"
9
10
  >
10
11
  <UiIcon
@@ -45,6 +46,7 @@ const emit = defineEmits<{
45
46
 
46
47
  const { t } = useI18n();
47
48
  const { show } = useSnackbar();
49
+ const { blockHover, hoverGuard } = useHoverGuard();
48
50
 
49
51
  /**
50
52
  * Снекбар с undo вшит в кнопку: отменять хочется только отписку, и текст у неё всегда
@@ -54,6 +56,7 @@ const { show } = useSnackbar();
54
56
  function onClick() {
55
57
  const wasFollowing = props.following;
56
58
 
59
+ blockHover();
57
60
  emit('toggle');
58
61
 
59
62
  if (!wasFollowing) return;
@@ -121,7 +124,10 @@ const ariaLabel = computed(() => props.following
121
124
  background-color: $follow-btn-following-bg;
122
125
  border-color: $follow-btn-following-border;
123
126
  color: $follow-btn-following-text;
127
+ }
124
128
 
129
+ /* `--no-hover` is set on click: the red «Unfollow» must not flash under the cursor right after following */
130
+ .ui-follow-btn.--following:not(.--no-hover) {
125
131
  @include hover(background-color, border-color, color) {
126
132
  background-color: $follow-btn-following-bg--hover;
127
133
  border-color: $follow-btn-following-border--hover;
@@ -144,17 +150,17 @@ const ariaLabel = computed(() => props.following
144
150
  }
145
151
 
146
152
  @media (hover: hover) {
147
- .ui-follow-btn.--following:is(:hover, :focus-visible) .ui-follow-btn__icon,
148
- .ui-follow-btn.--following:is(:hover, :focus-visible) .ui-follow-btn__label {
153
+ .ui-follow-btn.--following:not(.--no-hover):is(:hover, :focus-visible) .ui-follow-btn__icon,
154
+ .ui-follow-btn.--following:not(.--no-hover):is(:hover, :focus-visible) .ui-follow-btn__label {
149
155
  display: none;
150
156
  }
151
157
 
152
- .ui-follow-btn.--following:is(:hover, :focus-visible) .ui-follow-btn__icon-hover {
158
+ .ui-follow-btn.--following:not(.--no-hover):is(:hover, :focus-visible) .ui-follow-btn__icon-hover {
153
159
  display: block;
154
160
  }
155
161
 
156
162
  @include from-br(sm) {
157
- .ui-follow-btn.--following:is(:hover, :focus-visible) .ui-follow-btn__label-hover {
163
+ .ui-follow-btn.--following:not(.--no-hover):is(:hover, :focus-visible) .ui-follow-btn__label-hover {
158
164
  display: block;
159
165
  }
160
166
  }
@@ -4,7 +4,7 @@
4
4
  :class="{'--popup': popup}"
5
5
  class="ui-notification"
6
6
  @click="closeByBackdropClick"
7
- @cancel="close"
7
+ @cancel.prevent
8
8
  >
9
9
  <div class="ui-notification__wrapper">
10
10
  <header class="ui-notification__header">
@@ -56,6 +56,11 @@ const props = defineProps<{
56
56
  popup?: boolean
57
57
  }>();
58
58
 
59
+ // Только модальный вариант: немодальный — плашка вроде cookie-баннера, она висит на странице
60
+ // сама по себе и перехватывать ею каждый Esc незачем. `cancel` погашен в разметке, закрытие
61
+ // идёт общим стеком — браузер закрыл бы диалог мимо `modelValue`.
62
+ useEscapeClose(() => !!props.popup, () => close());
63
+
59
64
  onMounted(() => {
60
65
  if (props.popup) {
61
66
  notification.value.showModal();
@@ -8,7 +8,7 @@
8
8
  ]"
9
9
  class="ui-popup"
10
10
  @click="closeByBackdropClick"
11
- @cancel="close"
11
+ @cancel.prevent
12
12
  >
13
13
  <div class="ui-popup__wrapper">
14
14
  <header class="ui-popup__header">
@@ -94,6 +94,11 @@ const props = defineProps<{
94
94
  notModal?: boolean
95
95
  }>();
96
96
 
97
+ // Попап живёт в разметке только открытым, поэтому в стеке Esc он с самого монтирования.
98
+ // Нативный `cancel` погашен: браузер закрыл бы диалог мимо `modelValue`, а из-под открытого
99
+ // дропдауна — ещё и вместе с ним, за одно нажатие.
100
+ useEscapeClose(true, () => close());
101
+
97
102
  onMounted(() => {
98
103
  enterPopup();
99
104
  scrollLock();
@@ -28,7 +28,6 @@
28
28
 
29
29
  <script setup lang="ts">
30
30
  import { checkDeviceWidth } from '../../runtime';
31
- import { onClickOutside } from '@vueuse/core';
32
31
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
33
32
 
34
33
  const props = defineProps<{
@@ -44,11 +43,10 @@ const contentStyle = ref<Record<string, string>>({});
44
43
  const breakpoints = CSS_BREAKPOINTS;
45
44
  const { isMobile } = checkDeviceWidth(breakpoints);
46
45
 
47
- onClickOutside(tooltipRef, () => {
48
- if (isMobile.value) {
49
- isOpen.value = false;
50
- }
51
- });
46
+ // Клик мимо закрывает только на мобиле — на десктопе тултип и так уходит по mouseleave
47
+ useOverlayClose(tooltipRef, isOpen, () => {
48
+ isOpen.value = false;
49
+ }, { outside: isMobile });
52
50
 
53
51
  /** Контент в position: fixed — иначе его режет overflow скроллящихся родителей (список в попапе). */
54
52
  function open() {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A toggle button sitting under the cursor would show the hover style of its brand new state
3
+ * right after the click — «Unfollow» red instead of the «Follow» that was just pressed. Mutes
4
+ * that hover until the pointer (or focus) leaves the button and comes back.
5
+ *
6
+ * `hoverGuard` goes on the element as `v-bind`, `blockHover()` — into the click handler.
7
+ * The muted state is exposed as the `--no-hover` class, so the CSS side is a `:not(.--no-hover)`
8
+ * on the reversing hover rule only: the plain hover feedback stays untouched.
9
+ */
10
+ export function useHoverGuard() {
11
+ const blocked = ref(false);
12
+
13
+ function blockHover() {
14
+ blocked.value = true;
15
+ }
16
+
17
+ function releaseHover() {
18
+ blocked.value = false;
19
+ }
20
+
21
+ const hoverGuard = computed(() => ({
22
+ class: blocked.value ? '--no-hover' : null,
23
+ onPointerleave: releaseHover,
24
+ onBlur: releaseHover,
25
+ }));
26
+
27
+ return { blockHover, hoverGuard };
28
+ }
@@ -0,0 +1,92 @@
1
+ import type { MaybeRefOrGetter } from 'vue';
2
+ import type { MaybeComputedElementRef } from '@vueuse/core';
3
+ import { onClickOutside } from '@vueuse/core';
4
+
5
+ type CloseHandler = () => void;
6
+
7
+ // Стек, а не слушатель на каждый оверлей: Esc обязан закрывать только верхний слой — иначе
8
+ // дропдаун, открытый из попапа, уносил бы с собой и сам попап.
9
+ const handlers: CloseHandler[] = [];
10
+
11
+ let listening = false;
12
+
13
+ function onKeydown(event: KeyboardEvent) {
14
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
15
+
16
+ const handler = handlers.at(-1);
17
+ if (!handler) return;
18
+
19
+ event.preventDefault();
20
+ event.stopPropagation();
21
+ handler();
22
+ }
23
+
24
+ function listen() {
25
+ if (listening) return;
26
+
27
+ // capture: перехватываем раньше полей ввода внутри оверлея — иначе Esc уходит в них
28
+ document.addEventListener('keydown', onKeydown, true);
29
+ listening = true;
30
+ }
31
+
32
+ function unlisten() {
33
+ if (!listening || handlers.length) return;
34
+
35
+ document.removeEventListener('keydown', onKeydown, true);
36
+ listening = false;
37
+ }
38
+
39
+ /**
40
+ * Закрытие по Esc для оверлея без «клика мимо» — модального `<dialog>`, где снаружи закрывает
41
+ * бэкдроп. Нативный `cancel` у таких диалогов гасится (`@cancel.prevent`) и закрытие идёт сюда:
42
+ * браузер закрыл бы верхний диалог мимо нашего состояния, а стек снимает ровно один слой.
43
+ * @param isOpen - открыт ли оверлей сейчас; для компонентов, монтируемых только открытыми, — `true`
44
+ * @param close - закрытие оверлея
45
+ */
46
+ export const useEscapeClose = (isOpen: MaybeRefOrGetter<boolean>, close: CloseHandler) => {
47
+ if (import.meta.server) return;
48
+
49
+ const handler: CloseHandler = () => close();
50
+
51
+ function push() {
52
+ if (handlers.includes(handler)) return;
53
+
54
+ handlers.push(handler);
55
+ listen();
56
+ }
57
+
58
+ function pop() {
59
+ const index = handlers.indexOf(handler);
60
+ if (index === -1) return;
61
+
62
+ handlers.splice(index, 1);
63
+ unlisten();
64
+ }
65
+
66
+ watch(() => toValue(isOpen), (value) => value ? push() : pop(), { immediate: true });
67
+
68
+ onScopeDispose(pop);
69
+ };
70
+
71
+ /**
72
+ * Закрытие оверлея двумя привычными способами разом: клик мимо и Esc. Порознь их разносить нечего —
73
+ * пользователь ждёт обоих, а забытый Esc с виду неотличим от рабочего оверлея.
74
+ * @param target - корень оверлея: клик вне него считается «мимо»
75
+ * @param isOpen - открыт ли оверлей сейчас
76
+ * @param close - закрытие оверлея
77
+ * @param options.outside - условие для «клика мимо», когда Esc нужен, а клик — нет (тултип на десктопе)
78
+ */
79
+ export const useOverlayClose = (
80
+ target: MaybeComputedElementRef,
81
+ isOpen: MaybeRefOrGetter<boolean>,
82
+ close: CloseHandler,
83
+ options: { outside?: MaybeRefOrGetter<boolean> } = {},
84
+ ) => {
85
+ onClickOutside(target, () => {
86
+ if (!toValue(isOpen) || !toValue(options.outside ?? true)) return;
87
+
88
+ close();
89
+ });
90
+
91
+ useEscapeClose(isOpen, close);
92
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.935",
4
+ "version": "0.0.936",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {