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.
- package/components/card/video/actions.vue +12 -3
- package/components/filter/selects.vue +16 -12
- package/components/layout/burger.vue +5 -0
- package/components/layout/footer-language.vue +1 -3
- package/components/layout/header-user.vue +1 -2
- package/components/layout/header.vue +1 -4
- package/components/layout/niche.vue +1 -3
- package/components/search/main.vue +4 -3
- package/components/ui/chips-micro.vue +1 -2
- package/components/ui/dropdown.vue +2 -4
- package/components/ui/follow-btn.vue +10 -4
- package/components/ui/notification.vue +6 -1
- package/components/ui/popup.vue +6 -1
- package/components/ui/tooltip.vue +4 -6
- package/composables/use-hover-guard.ts +28 -0
- package/composables/use-overlay-close.ts +92 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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();
|
package/components/ui/popup.vue
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
]"
|
|
9
9
|
class="ui-popup"
|
|
10
10
|
@click="closeByBackdropClick"
|
|
11
|
-
@cancel
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
};
|