itube-specs 0.0.912 → 0.0.913

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.
@@ -2,22 +2,23 @@
2
2
  @use '../mixins' as *;
3
3
 
4
4
  /**
5
- Анимация открытия попапов, для анимации открытия попапа обернуть SPopup в transition
5
+ Анимация открытия попапов, для анимации открытия попапа обернуть попап в transition.
6
+ Гейты `--no-enter-anim` / `--no-leave-anim` вешает useOverlayStack: появление
7
+ анимируется только у первого оверлея в стеке, закрытие — только у последнего.
8
+ Негации завёрнуты в `:where()`, чтобы не поднимать вес селектора — иначе они начинают
9
+ спорить с правилами компонентов (`.ui-snackbar.v-enter-from` и Ко).
6
10
  */
7
- .v-enter-active,
8
- .v-leave-active {
9
- transition: 100ms ease opacity;
11
+ .v-enter-active:where(:not(.--no-enter-anim)),
12
+ .v-leave-active:where(:not(.--no-leave-anim)) {
13
+ transition: 200ms linear opacity, 200ms linear transform;
10
14
 
11
15
  @include from-br(sm) {
12
16
  transition: 300ms linear opacity, 200ms linear transform;
13
17
  }
14
18
  }
15
19
 
16
- .v-enter-from,
17
- .v-leave-to {
20
+ .v-enter-from:where(:not(.--no-enter-anim)),
21
+ .v-leave-to:where(:not(.--no-leave-anim)) {
18
22
  opacity: 0;
19
-
20
- @include from-br(sm) {
21
- transform: scale(0.99) translateY(8px);
22
- }
23
+ transform: scale(0.99) translateY(8px);
23
24
  }
@@ -48,9 +48,12 @@
48
48
  line-height: 1.333;
49
49
  }
50
50
 
51
+ @mixin font-3xl {
52
+ font-size: 30px;
53
+ line-height: 1.2;
54
+ }
55
+
51
56
  @mixin font-4xl {
52
57
  font-size: 36px;
53
58
  line-height: 1.111;
54
59
  }
55
-
56
-
@@ -59,8 +59,10 @@ $chips-bg--active: $color-zinc-700;
59
59
  $chips-text: $color-zinc-400;
60
60
  $chips-text--hover: $color-white;
61
61
  $chips-text--active: $color-white;
62
- $chips-micro-bg: $color-zinc-800;
63
- $chips-micro-text: $color-zinc-400;
62
+ $chips-micro-bg: $color-zinc-800-80;
63
+ $chips-micro-bg--hover: $color-zinc-700;
64
+ $chips-micro-text: $color-zinc-300;
65
+ $chips-micro-text--hover: $color-white;
64
66
  $chips-small-bg: $color-zinc-800-50;
65
67
  $chips-small-bg--hover: $color-zinc-700;
66
68
  $chips-small-text: $color-zinc-400;
@@ -200,8 +202,8 @@ $video-card-dropdown-icon--hover: $color-white;
200
202
  $video-card-secondary: $color-zinc-500;
201
203
  $video-card-mini-title: $color-zinc-300;
202
204
  $video-card-mini-weight: 400;
203
- $video-card-user-text: $color-zinc-400;
204
- $video-card-user-bg: $color-zinc-700;
205
+ $video-card-user-text: $color-zinc-500;
206
+ $video-card-user-bg: $color-zinc-800;
205
207
  $video-card-progress-bg: $mono-white-50;
206
208
  $video-card-progress-fill: $color-indigo-500;
207
209
  $video-card-remaining: $color-indigo-400;
@@ -698,6 +700,7 @@ $filter-popup--active: $color-zinc-400;
698
700
  $filter-popup-active-bg: $color-zinc-800;
699
701
  $filter-popup-quick-title: $color-zinc-400;
700
702
  $filter-popup-group-border: $color-zinc-800;
703
+ $filter-popup-summary-arrow: $color-zinc-400;
701
704
  $filter-popup-summary-description: $color-zinc-500;
702
705
  $filter-popup-active-chip-bg: $color-indigo-600;
703
706
  $filter-popup-active-chip-bg--hover: $color-indigo-500;
@@ -72,7 +72,9 @@ $chips-text: $color-gray-700;
72
72
  $chips-text--hover: $color-gray-900;
73
73
  $chips-text--active: $color-white;
74
74
  $chips-micro-bg: $color-gray-300;
75
+ $chips-micro-bg--hover: $color-gray-400;
75
76
  $chips-micro-text: $color-gray-700;
77
+ $chips-micro-text--hover: $color-gray-900;
76
78
  $chips-small-bg: $color-gray-300;
77
79
  $chips-small-bg--hover: $color-gray-400;
78
80
  $chips-small-text: $color-gray-700;
@@ -749,6 +751,7 @@ $filter-popup--active: $color-gray-400;
749
751
  $filter-popup-active-bg: transparent;
750
752
  $filter-popup-quick-title: $color-gray-400;
751
753
  $filter-popup-group-border: transparent;
754
+ $filter-popup-summary-arrow: $color-gray-400;
752
755
  $filter-popup-summary-description: $color-gray-500;
753
756
  $filter-popup-active-chip-bg: $color-indigo-600;
754
757
  $filter-popup-active-chip-bg--hover: $color-indigo-500;
@@ -21,7 +21,7 @@
21
21
  // ─── Project-specific ───
22
22
  --chips-height: 32px;
23
23
  --chips-height-small: 24px;
24
- --chips-height-micro: 20px;
24
+ --chips-height-micro: 24px;
25
25
  --transition: var(--default-transition-duration) var(--default-transition-timing-function);
26
26
  --header-height: 64px;
27
27
  --control-min-width: 126px;
@@ -3,6 +3,7 @@ $spacing: 4px;
3
3
  // ─── Border radius ───
4
4
  $radius: 10px;
5
5
  $radius-small: 4px;
6
+ $radius-6: 6px;
6
7
  $radius-md: 8px;
7
8
  $radius-lg: 10px;
8
9
  $radius-12: 12px;
@@ -118,7 +118,7 @@ const images = computed(() => {
118
118
  }
119
119
 
120
120
  .card-collections-more__count {
121
- @include font-2xl;
121
+ @include font-3xl;
122
122
 
123
123
  font-weight: 700;
124
124
  color: $collections-more-count;
@@ -31,7 +31,7 @@
31
31
  v-if="showWatchLater"
32
32
  :class="className"
33
33
  type="button"
34
- @click="toggleWatchLater(card.guid)"
34
+ @click="toggleWatchLater(card.guid, card.title)"
35
35
  >
36
36
  <UiIcon
37
37
  name="clock"
@@ -43,7 +43,7 @@
43
43
  v-if="showFavorites"
44
44
  :class="className"
45
45
  type="button"
46
- @click="toggleFavorite(card.guid)"
46
+ @click="toggleFavorite(card.guid, card.title)"
47
47
  >
48
48
  <UiIcon
49
49
  name="heart-stroke"
@@ -55,7 +55,7 @@
55
55
  >
56
56
  <UiIcon
57
57
  name="thumbs-up"
58
- size="12"
58
+ size="14"
59
59
  />
60
60
  {{ $n(card.likes, 'compact') }}
61
61
  </span>
@@ -73,7 +73,7 @@
73
73
  <p class="card-playlist__created">
74
74
  <UiIcon
75
75
  name="date"
76
- size="12"
76
+ size="14"
77
77
  />
78
78
  {{ formatDate(card.created) }}
79
79
  </p>
@@ -83,7 +83,7 @@
83
83
  v-if="avatar"
84
84
  :src="avatar"
85
85
  :alt="card.username"
86
- class="card-playlist__avatar _from-xs"
86
+ class="card-collections-more__count _from-xs"
87
87
  width="36"
88
88
  height="36"
89
89
  sizes="36px"
@@ -292,7 +292,7 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
292
292
  .card-playlist__wrapper {
293
293
  display: grid;
294
294
  align-items: center;
295
- padding: 16px;
295
+ padding: 12px;
296
296
  }
297
297
 
298
298
  .card-playlist__wrapper-header {
@@ -300,7 +300,7 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
300
300
  align-items: center;
301
301
  justify-content: space-between;
302
302
  column-gap: 24px;
303
- margin-bottom: 12px;
303
+ margin-bottom: 8px;
304
304
  }
305
305
 
306
306
  .card-playlist__wrapper.--top {
@@ -407,8 +407,8 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
407
407
  --line-clamp: 1;
408
408
 
409
409
  color: $playlist-card-tags;
410
- padding-top: 12px;
411
- margin-top: 12px;
410
+ padding-top: 8px;
411
+ margin-top: 8px;
412
412
  border-top: 1px solid $playlist-card-tags-border;
413
413
  }
414
414
 
@@ -456,7 +456,6 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
456
456
  grid-template-columns: auto 1fr;
457
457
  column-gap: 12px;
458
458
  align-items: start;
459
- margin-bottom: 12px;
460
459
  }
461
460
 
462
461
  .card-playlist__content-wrapper {
@@ -7,7 +7,7 @@
7
7
  class="card-video-actions__btn --active"
8
8
  type="button"
9
9
  aria-pressed="true"
10
- @click="toggleFavorite(card.guid)"
10
+ @click="toggleFavorite(card.guid, card.title)"
11
11
  >
12
12
  <UiIcon
13
13
  name="heart"
@@ -22,7 +22,7 @@
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)"
25
+ @click="toggleWatchLater(card.guid, card.title)"
26
26
  >
27
27
  <UiIcon
28
28
  :name="later ? 'clock-filled' : 'clock'"
@@ -398,14 +398,14 @@ $dropdown-column: 18px;
398
398
  }
399
399
 
400
400
  @include from-cq($layout-breakpoint) {
401
- column-gap: 8px;
402
- grid-template-columns: 32px 1fr $dropdown-column;
401
+ column-gap: 12px;
402
+ grid-template-columns: 36px 1fr $dropdown-column;
403
403
  }
404
404
  }
405
405
 
406
406
  .--avatar .card-video__wrapper {
407
- column-gap: 8px;
408
- grid-template-columns: 32px 1fr $dropdown-column;
407
+ column-gap: 12px;
408
+ grid-template-columns: 36px 1fr $dropdown-column;
409
409
  }
410
410
 
411
411
  .--avatar .card-video__title {
@@ -585,23 +585,23 @@ $dropdown-column: 18px;
585
585
 
586
586
  @include from-cq($layout-breakpoint) {
587
587
  grid-column: 2;
588
- margin-top: 8px;
588
+ margin-top: 10px;
589
589
  }
590
590
  }
591
591
 
592
592
  .card-video__tags-wrapper {
593
- max-height: calc( var(--chips-height-micro) * 2 + 2px);
593
+ max-height: calc( var(--chips-height-micro) * 2 + 8px);
594
594
  overflow: hidden;
595
595
  display: flex;
596
596
  flex-wrap: wrap;
597
- gap: 2px;
597
+ gap: 8px;
598
598
  }
599
599
 
600
600
  .card-video__user {
601
601
  @include font-xs;
602
602
 
603
- width: 32px;
604
- height: 32px;
603
+ width: 36px;
604
+ height: 36px;
605
605
  display: grid;
606
606
  place-content: center;
607
607
  font-weight: 700;
@@ -288,6 +288,7 @@ const { t } = useI18n();
288
288
 
289
289
  .filter-popup__summary-arrow {
290
290
  flex-shrink: 0;
291
+ color: $filter-popup-summary-arrow;
291
292
  transition: transform var(--transition);
292
293
  }
293
294
 
@@ -199,7 +199,7 @@ const ageText = computed(() => {
199
199
  grid-template-columns: auto minmax(0, 1fr) auto;
200
200
  column-gap: 16px;
201
201
  row-gap: 12px;
202
- align-items: center;
202
+ align-items: start;
203
203
  margin-bottom: 20px;
204
204
  }
205
205
 
@@ -8,7 +8,7 @@
8
8
  <UiSectionTitle
9
9
  :title="t('collections_by')"
10
10
  title-tag="h2"
11
- size="xl"
11
+ variant="section"
12
12
  >
13
13
  <span class="section-collections__badge">{{ t('editorial') }}</span>
14
14
  </UiSectionTitle>
@@ -86,7 +86,7 @@ const restThumbs = computed(() => {
86
86
  align-items: center;
87
87
  justify-content: space-between;
88
88
  column-gap: 16px;
89
- margin-bottom: 24px;
89
+ margin-bottom: 16px;
90
90
  }
91
91
 
92
92
  .section-collections__badge {
@@ -6,7 +6,7 @@
6
6
  :title="title || t('top_models')"
7
7
  :count="count ?? items.length"
8
8
  :icon="icon"
9
- :size="size"
9
+ :variant="variant"
10
10
  class="section-swiper-cards__title"
11
11
  title-tag="h2"
12
12
  />
@@ -70,8 +70,8 @@ withDefaults(defineProps<{
70
70
  /** Счётчик в шапке; по умолчанию — число карточек */
71
71
  count?: string | number
72
72
  icon?: string
73
- /** Размер заголовка секции — см. `UiSectionTitle` */
74
- size?: 'lg' | 'xl'
73
+ /** Вариант заголовка — см. `UiSectionTitle` */
74
+ variant?: 'page' | 'section'
75
75
  placeholderAvatar?: string
76
76
  /** Карточки только с аватаром и названием, без счётчика роликов */
77
77
  withoutCount?: boolean
@@ -184,11 +184,16 @@ const name = computed(() => {
184
184
  @include font-xs;
185
185
 
186
186
  padding-block: 4px;
187
- padding-inline: 8px;
187
+ padding-inline: 10px;
188
188
  height: var(--chips-height-micro);
189
- border-radius: 4px;
189
+ border-radius: $radius-6;
190
190
  background-color: $chips-micro-bg;
191
191
  color: $chips-micro-text;
192
+
193
+ @include hover(background-color, color) {
194
+ background-color: $chips-micro-bg--hover;
195
+ color: $chips-micro-text--hover;
196
+ }
192
197
  }
193
198
 
194
199
  .ui-chips.--not-capitalize {
@@ -89,6 +89,8 @@ onClickOutside(dropdownRef, () => {
89
89
 
90
90
  const { scrollLock, scrollUnlock } = useGlobalScrollLock();
91
91
 
92
+ const { registerOverlay, releaseOverlay } = useOverlayStack();
93
+
92
94
  const breakpoints = CSS_BREAKPOINTS;
93
95
  const { isTablet } = checkDeviceWidth(breakpoints);
94
96
 
@@ -126,17 +128,22 @@ function close() {
126
128
  }
127
129
 
128
130
  watch(open, (val) => {
129
- if (!isTablet.value) return;
130
131
  if (val) {
132
+ if (!isTablet.value) return;
131
133
  scrollLock();
134
+ // На мобиле меню — модальный шит с анимацией, поэтому он тоже участник стека
135
+ // оверлеев: попап, открытый из него, не должен появляться с анимацией.
136
+ nextTick(() => registerOverlay(menuRef.value));
132
137
  } else {
133
- scrollUnlock();
138
+ if (isTablet.value) scrollUnlock();
139
+ releaseOverlay(menuRef.value);
134
140
  }
135
141
  });
136
142
 
137
143
  onBeforeUnmount(() => {
138
144
  if (open.value && isTablet.value) {
139
145
  scrollUnlock();
146
+ releaseOverlay(menuRef.value);
140
147
  }
141
148
  });
142
149
 
@@ -373,25 +380,27 @@ defineExpose({ openDropdown, open });
373
380
  }
374
381
  }
375
382
 
383
+ /* Гейты `--no-*-anim` вешает useOverlayStack: шит не анимируется, если он открывается
384
+ поверх попапа или закрывается, отдавая место другому оверлею. */
376
385
  @include to-br(md) {
377
- .ui-dropdown-enter-active,
378
- .ui-dropdown-leave-active {
386
+ .ui-dropdown__menu:where(:not(.--no-enter-anim)).ui-dropdown-enter-active,
387
+ .ui-dropdown__menu:where(:not(.--no-leave-anim)).ui-dropdown-leave-active {
379
388
  transition: transform 300ms ease;
380
389
  }
381
390
 
382
- .ui-dropdown-enter-from {
391
+ .ui-dropdown__menu:where(:not(.--no-enter-anim)).ui-dropdown-enter-from {
383
392
  transform: translateY(100%);
384
393
  }
385
394
 
386
- .ui-dropdown-enter-to {
395
+ .ui-dropdown__menu:where(:not(.--no-enter-anim)).ui-dropdown-enter-to {
387
396
  transform: translateY(0);
388
397
  }
389
398
 
390
- .ui-dropdown-leave-from {
399
+ .ui-dropdown__menu:where(:not(.--no-leave-anim)).ui-dropdown-leave-from {
391
400
  transform: translateY(0);
392
401
  }
393
402
 
394
- .ui-dropdown-leave-to {
403
+ .ui-dropdown__menu:where(:not(.--no-leave-anim)).ui-dropdown-leave-to {
395
404
  transform: translateY(100%);
396
405
  }
397
406
  }
@@ -219,10 +219,6 @@ onBeforeUnmount(() => {
219
219
  height: var(--chips-height-micro);
220
220
  }
221
221
 
222
- .ui-expand-row.--micro .ui-expand-row__wrapper {
223
- gap: 4px;
224
- }
225
-
226
222
  .ui-expand-row__button.--more {
227
223
  text-transform: capitalize;
228
224
  }
@@ -75,6 +75,8 @@ const { scrollLock, scrollUnlock } = useGlobalScrollLock();
75
75
 
76
76
  const { snackbarText, isSnackBarInPopup, enterPopup, leavePopup } = useSnackbar();
77
77
 
78
+ const { registerOverlay, releaseOverlay } = useOverlayStack();
79
+
78
80
  const popupRef = ref();
79
81
  const emit = defineEmits<{
80
82
  (eventName: 'update:modelValue', value: boolean): void
@@ -95,6 +97,7 @@ const props = defineProps<{
95
97
  onMounted(() => {
96
98
  enterPopup();
97
99
  scrollLock();
100
+ registerOverlay(popupRef.value);
98
101
  if (props.notModal) {
99
102
  popupRef.value.show();
100
103
  } else {
@@ -106,6 +109,7 @@ onMounted(() => {
106
109
  onBeforeUnmount(() => {
107
110
  leavePopup();
108
111
  scrollUnlock();
112
+ releaseOverlay(popupRef.value);
109
113
  });
110
114
 
111
115
  function close() {
@@ -351,14 +355,14 @@ function closeByBackdropClick(event: Event) {
351
355
  padding: 20px;
352
356
  }
353
357
 
354
- .ui-popup.--drawer {
355
- &.v-enter-active,
356
- &.v-leave-active {
358
+ /* Появление анимируется только у первого оверлея в стеке, закрытие — только у последнего
359
+ (см. useOverlayStack): иначе на переходе попап→попап диалоги едут навстречу друг другу. */
360
+ .ui-popup.--drawer:where(:not(.--no-enter-anim)) {
361
+ &.v-enter-active {
357
362
  transition: transform 300ms ease;
358
363
  }
359
364
 
360
- &.v-enter-active::backdrop,
361
- &.v-leave-active::backdrop {
365
+ &.v-enter-active::backdrop {
362
366
  transition: opacity 300ms ease;
363
367
  }
364
368
 
@@ -373,6 +377,16 @@ function closeByBackdropClick(event: Event) {
373
377
  &.v-enter-to {
374
378
  transform: translateX(0);
375
379
  }
380
+ }
381
+
382
+ .ui-popup.--drawer:where(:not(.--no-leave-anim)) {
383
+ &.v-leave-active {
384
+ transition: transform 300ms ease;
385
+ }
386
+
387
+ &.v-leave-active::backdrop {
388
+ transition: opacity 300ms ease;
389
+ }
376
390
 
377
391
  &.v-leave-from {
378
392
  transform: translateX(0);
@@ -387,10 +401,9 @@ function closeByBackdropClick(event: Event) {
387
401
  }
388
402
  }
389
403
 
390
- .ui-popup.--sheet {
404
+ .ui-popup.--sheet:where(:not(.--no-enter-anim)) {
391
405
  @include to-br(sm) {
392
- &.v-enter-active,
393
- &.v-leave-active {
406
+ &.v-enter-active {
394
407
  transition: transform 300ms ease;
395
408
  }
396
409
 
@@ -401,6 +414,14 @@ function closeByBackdropClick(event: Event) {
401
414
  &.v-enter-to {
402
415
  transform: translateY(0);
403
416
  }
417
+ }
418
+ }
419
+
420
+ .ui-popup.--sheet:where(:not(.--no-leave-anim)) {
421
+ @include to-br(sm) {
422
+ &.v-leave-active {
423
+ transition: transform 300ms ease;
424
+ }
404
425
 
405
426
  &.v-leave-from {
406
427
  transform: translateY(0);
@@ -38,16 +38,16 @@ const props = withDefaults(defineProps<{
38
38
  title: string
39
39
  icon?: string
40
40
  img?: string
41
- /** `lg` — страничный заголовок (18px, капс); `xl` — заголовок секции (20px, без капса) */
42
- size?: 'lg' | 'xl'
41
+ /** `page` — страничный заголовок (18px, капс, 700); `section` — заголовок секции (18px, без капса, 600) */
42
+ variant?: 'page' | 'section'
43
43
  }>(), {
44
44
  titleTag: 'h1',
45
- size: 'lg'
45
+ variant: 'page'
46
46
  });
47
47
 
48
48
  const titleClass = computed(() => [
49
49
  'ui-section-title__title',
50
- props.size === 'xl' ? '--xl' : '_title'
50
+ props.variant === 'section' ? '--section' : '_title'
51
51
  ]);
52
52
  </script>
53
53
 
@@ -59,8 +59,8 @@ const titleClass = computed(() => [
59
59
  vertical-align: middle;
60
60
  }
61
61
 
62
- .ui-section-title__title.--xl {
63
- @include font-xl;
62
+ .ui-section-title__title.--section {
63
+ @include font-lg;
64
64
 
65
65
  font-weight: 600;
66
66
  }
@@ -0,0 +1,110 @@
1
+ // @vitest-environment nuxt
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+ import { useOverlayStack } from '../use-overlay-stack';
4
+
5
+ const NO_ENTER = '--no-enter-anim';
6
+ const NO_LEAVE = '--no-leave-anim';
7
+
8
+ let frames: Array<() => void> = [];
9
+
10
+ function flushFrames() {
11
+ const queued = frames;
12
+ frames = [];
13
+ queued.forEach((cb) => cb());
14
+ }
15
+
16
+ function createOverlay() {
17
+ const el = document.createElement('dialog');
18
+ document.body.appendChild(el);
19
+
20
+ return el;
21
+ }
22
+
23
+ describe('useOverlayStack', () => {
24
+ beforeEach(() => {
25
+ frames = [];
26
+ vi.stubGlobal('requestAnimationFrame', (cb: () => void) => {
27
+ frames.push(cb);
28
+
29
+ return 0;
30
+ });
31
+ });
32
+
33
+ afterEach(() => {
34
+ flushFrames();
35
+ document.body.innerHTML = '';
36
+ vi.unstubAllGlobals();
37
+ });
38
+
39
+ it('первый оверлей анимируется в обе стороны', () => {
40
+ const { registerOverlay } = useOverlayStack();
41
+ const el = createOverlay();
42
+
43
+ registerOverlay(el);
44
+
45
+ expect(el.classList.contains(NO_ENTER)).toBe(false);
46
+ expect(el.classList.contains(NO_LEAVE)).toBe(false);
47
+ });
48
+
49
+ it('второй оверлей открывается без анимации появления', () => {
50
+ const { registerOverlay } = useOverlayStack();
51
+ const first = createOverlay();
52
+ const second = createOverlay();
53
+
54
+ registerOverlay(first);
55
+ registerOverlay(second);
56
+
57
+ expect(second.classList.contains(NO_ENTER)).toBe(true);
58
+ });
59
+
60
+ it('пока оверлеев больше одного, закрытие не анимируется ни у одного', () => {
61
+ const { registerOverlay } = useOverlayStack();
62
+ const first = createOverlay();
63
+ const second = createOverlay();
64
+
65
+ registerOverlay(first);
66
+ registerOverlay(second);
67
+
68
+ expect(first.classList.contains(NO_LEAVE)).toBe(true);
69
+ expect(second.classList.contains(NO_LEAVE)).toBe(true);
70
+ });
71
+
72
+ it('уходящий оверлей остаётся в стеке до конца кадра — сменщик успевает открыться без анимации', () => {
73
+ const { registerOverlay, releaseOverlay } = useOverlayStack();
74
+ const closing = createOverlay();
75
+ const opening = createOverlay();
76
+
77
+ registerOverlay(closing);
78
+ releaseOverlay(closing);
79
+ registerOverlay(opening);
80
+
81
+ expect(opening.classList.contains(NO_ENTER)).toBe(true);
82
+ expect(closing.classList.contains(NO_LEAVE)).toBe(true);
83
+ });
84
+
85
+ it('оставшийся последним снова закрывается с анимацией', () => {
86
+ const { registerOverlay, releaseOverlay } = useOverlayStack();
87
+ const closing = createOverlay();
88
+ const opening = createOverlay();
89
+
90
+ registerOverlay(closing);
91
+ releaseOverlay(closing);
92
+ registerOverlay(opening);
93
+ flushFrames();
94
+
95
+ expect(opening.classList.contains(NO_LEAVE)).toBe(false);
96
+ });
97
+
98
+ it('оверлей, удалённый из DOM без релиза, не блокирует анимации следующему', () => {
99
+ const { registerOverlay } = useOverlayStack();
100
+ const leaked = createOverlay();
101
+ const next = createOverlay();
102
+
103
+ registerOverlay(leaked);
104
+ leaked.remove();
105
+ registerOverlay(next);
106
+
107
+ expect(next.classList.contains(NO_ENTER)).toBe(false);
108
+ expect(next.classList.contains(NO_LEAVE)).toBe(false);
109
+ });
110
+ });
@@ -0,0 +1,75 @@
1
+ // @vitest-environment nuxt
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+ import { useVideoCollections } from '../use-video-collections';
4
+ import { useSnackbar } from '../use-snackbar';
5
+
6
+ describe('useVideoCollections', () => {
7
+ beforeEach(() => {
8
+ useNuxtApp().payload.state = {};
9
+ vi.useFakeTimers();
10
+ useSnackbar().resetSnackbar();
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+
17
+ it('добавляет в «смотреть позже» и показывает снекбар с кнопкой отмены', () => {
18
+ const { watchLater, isWatchLater, toggleWatchLater } = useVideoCollections();
19
+ const { snackbarText, snackbarIcon, snackbarHighlight, snackbarActionText } = useSnackbar();
20
+
21
+ toggleWatchLater('guid-1', 'Video title');
22
+
23
+ expect(watchLater.value).toEqual([ 'guid-1' ]);
24
+ expect(isWatchLater('guid-1')).toBe(true);
25
+ expect(snackbarText.value).toBe('added');
26
+ expect(snackbarIcon.value).toBe('clock');
27
+ expect(snackbarHighlight.value).toBe('Video title');
28
+ expect(snackbarActionText.value).toBe('undo');
29
+ });
30
+
31
+ it('удаление показывает снекбар «removed»', () => {
32
+ const { watchLater, toggleWatchLater } = useVideoCollections();
33
+ const { snackbarText } = useSnackbar();
34
+
35
+ toggleWatchLater('guid-1', 'Video title');
36
+ toggleWatchLater('guid-1', 'Video title');
37
+
38
+ expect(watchLater.value).toEqual([]);
39
+ expect(snackbarText.value).toBe('removed');
40
+ });
41
+
42
+ it('избранное показывает снекбар с иконкой сердца', () => {
43
+ const { favorites, isFavorite, toggleFavorite } = useVideoCollections();
44
+ const { snackbarIcon, snackbarText } = useSnackbar();
45
+
46
+ toggleFavorite('guid-2', 'Another title');
47
+
48
+ expect(favorites.value).toEqual([ 'guid-2' ]);
49
+ expect(isFavorite('guid-2')).toBe(true);
50
+ expect(snackbarIcon.value).toBe('heart');
51
+ expect(snackbarText.value).toBe('added');
52
+ });
53
+
54
+ it('отмена возвращает список и не показывает новый снекбар', () => {
55
+ const { favorites, toggleFavorite } = useVideoCollections();
56
+ const { runSnackbarAction, snackbarText } = useSnackbar();
57
+
58
+ toggleFavorite('guid-2', 'Another title');
59
+ runSnackbarAction();
60
+
61
+ expect(favorites.value).toEqual([]);
62
+ expect(snackbarText.value).toBe('');
63
+ });
64
+
65
+ it('отмена удаления возвращает ролик обратно', () => {
66
+ const { watchLater, toggleWatchLater } = useVideoCollections();
67
+ const { runSnackbarAction } = useSnackbar();
68
+
69
+ toggleWatchLater('guid-3', 'Third title');
70
+ toggleWatchLater('guid-3', 'Third title');
71
+ runSnackbarAction();
72
+
73
+ expect(watchLater.value).toEqual([ 'guid-3' ]);
74
+ });
75
+ });
@@ -0,0 +1,58 @@
1
+ const NO_ENTER_ANIM_CLASS = '--no-enter-anim';
2
+ const NO_LEAVE_ANIM_CLASS = '--no-leave-anim';
3
+
4
+ // Элементы, а не счётчик: класс вешается прямо на DOM. Уходящий компонент управлять
5
+ // своим `:class` уже не может — Vue гасит его реактивный эффект в unmount, до конца
6
+ // leave-транзишена, поэтому ре-рендер по реактивному флагу не случится.
7
+ const overlays = new Set<HTMLElement>();
8
+
9
+ function syncLeaveAnimation() {
10
+ const chained = overlays.size > 1;
11
+ overlays.forEach((el) => el.classList.toggle(NO_LEAVE_ANIM_CLASS, chained));
12
+ }
13
+
14
+ /**
15
+ * Стек открытых оверлеев (попапы и мобильные шиты дропдауна). Нужен, чтобы анимация
16
+ * появления играла только у первого оверлея, а анимация закрытия — только у последнего:
17
+ * при переходе попап→попап уходящий и приходящий диалоги ехали бы навстречу друг другу
18
+ * и картинка скакала бы.
19
+ */
20
+ export const useOverlayStack = () => {
21
+ /**
22
+ * Решение по анимации появления принимается один раз и дальше не меняется: состав
23
+ * стека успеет измениться до того, как браузер прочитает transition уходящего.
24
+ */
25
+ function registerOverlay(el?: HTMLElement | null) {
26
+ if (!import.meta.client || !el) return;
27
+
28
+ overlays.forEach((item) => {
29
+ if (!item.isConnected) overlays.delete(item);
30
+ });
31
+
32
+ el.classList.toggle(NO_ENTER_ANIM_CLASS, overlays.size > 0);
33
+ overlays.add(el);
34
+ syncLeaveAnimation();
35
+ }
36
+
37
+ /**
38
+ * Выпуск отложен на кадр: следующий оверлей монтируется позже unmount-хука уходящего
39
+ * (а ленивый — ещё и через микротаск). Убрать элемент из стека сразу — уходящий не
40
+ * успеет узнать, что ему на смену открывают другой попап, и проиграет закрытие.
41
+ */
42
+ function releaseOverlay(el?: HTMLElement | null) {
43
+ if (!import.meta.client || !el) return;
44
+
45
+ const release = () => {
46
+ overlays.delete(el);
47
+ syncLeaveAnimation();
48
+ };
49
+
50
+ if (typeof requestAnimationFrame === 'function') {
51
+ requestAnimationFrame(release);
52
+ } else {
53
+ release();
54
+ }
55
+ }
56
+
57
+ return { registerOverlay, releaseOverlay };
58
+ };
@@ -7,17 +7,39 @@ export const useVideoCollections = () => {
7
7
  const favorites = useState<string[]>('video-collections-favorites', () => []);
8
8
  const watchLater = useState<string[]>('video-collections-watch-later', () => []);
9
9
 
10
+ const { show } = useSnackbar();
11
+
12
+ /** Возвращает true, если ролик добавлен, и false — если убран. */
10
13
  function toggle(list: Ref<string[]>, guid: string) {
11
- list.value = list.value.includes(guid)
12
- ? list.value.filter(item => item !== guid)
13
- : [ ...list.value, guid ];
14
+ const added = !list.value.includes(guid);
15
+
16
+ list.value = added
17
+ ? [ ...list.value, guid ]
18
+ : list.value.filter(item => item !== guid);
19
+
20
+ return added;
21
+ }
22
+
23
+ // Снекбар показывается только на исходное действие: откат возвращает список как был,
24
+ // повторно сообщать об этом нечего — иначе отмена сама выглядит как новое изменение.
25
+ function notify(added: boolean, title: string, icon: string, undo: () => void) {
26
+ show(added ? 'added' : 'removed', {
27
+ icon,
28
+ highlight: title,
29
+ onAction: undo,
30
+ });
14
31
  }
15
32
 
16
33
  const isFavorite = (guid: string) => favorites.value.includes(guid);
17
34
  const isWatchLater = (guid: string) => watchLater.value.includes(guid);
18
35
 
19
- const toggleFavorite = (guid: string) => toggle(favorites, guid);
20
- const toggleWatchLater = (guid: string) => toggle(watchLater, guid);
36
+ const toggleFavorite = (guid: string, title = '') => {
37
+ notify(toggle(favorites, guid), title, 'heart', () => toggle(favorites, guid));
38
+ };
39
+
40
+ const toggleWatchLater = (guid: string, title = '') => {
41
+ notify(toggle(watchLater, guid), title, 'clock', () => toggle(watchLater, guid));
42
+ };
21
43
 
22
44
  return {
23
45
  favorites,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.912",
4
+ "version": "0.0.913",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -17,7 +17,7 @@
17
17
  :header-link-text="t('following.all_models')"
18
18
  all-link="/library/models"
19
19
  icon="user"
20
- size="xl"
20
+ variant="section"
21
21
  placeholder-avatar="/img/model-placeholder.webp"
22
22
  class="following__section"
23
23
  without-count
@@ -32,7 +32,7 @@
32
32
  :header-link-text="t('following.all_channels')"
33
33
  all-link="/library/channels"
34
34
  icon="play-circle"
35
- size="xl"
35
+ variant="section"
36
36
  placeholder-avatar="/img/logo-placeholder.svg"
37
37
  class="following__section"
38
38
  without-count
@@ -45,7 +45,7 @@
45
45
  :count="t('following.updates', { count: categoriesUpdates })"
46
46
  icon="grid"
47
47
  title-tag="h2"
48
- size="xl"
48
+ variant="section"
49
49
  />
50
50
 
51
51
  <!-- На мобиле плитки «все» в гриде нет, поэтому ссылка вынесена в шапку -->
@@ -79,7 +79,7 @@
79
79
  :count="t('following.updates', { count: playlistsUpdates })"
80
80
  icon="playlist"
81
81
  title-tag="h2"
82
- size="xl"
82
+ variant="section"
83
83
  />
84
84
 
85
85
  <UiLink
package/pages/history.vue CHANGED
@@ -55,7 +55,7 @@
55
55
  class="history__group-header"
56
56
  icon="video-camera"
57
57
  title-tag="h2"
58
- size="xl"
58
+ variant="section"
59
59
  />
60
60
 
61
61
  <div class="history__grid">
@@ -25,14 +25,14 @@
25
25
  :title="t('library.continue_watching')"
26
26
  icon="history"
27
27
  title-tag="h2"
28
- size="xl"
28
+ variant="section"
29
29
  />
30
30
 
31
31
  <UiLink
32
32
  :to="generateLink('/history')"
33
33
  theme="secondary"
34
34
  >
35
- {{ t('library.view_all_history') }}
35
+ {{ t('library.history') }}
36
36
  <UiIcon
37
37
  name="arrow-right"
38
38
  size="16"
@@ -61,7 +61,7 @@
61
61
  :title="t('library.collections')"
62
62
  icon="layers"
63
63
  title-tag="h2"
64
- size="xl"
64
+ variant="section"
65
65
  />
66
66
  </header>
67
67
 
@@ -86,7 +86,7 @@
86
86
  :title="t('library.following')"
87
87
  icon="plus"
88
88
  title-tag="h2"
89
- size="xl"
89
+ variant="section"
90
90
  />
91
91
 
92
92
  <UiLink
@@ -13,7 +13,7 @@
13
13
  v-if="h1"
14
14
  :title="h1"
15
15
  :count="publicData.items.length"
16
- icon="list"
16
+ icon="playlist"
17
17
  />
18
18
 
19
19
  <UiLink
@@ -25,7 +25,7 @@
25
25
  name="playlist"
26
26
  size="16"
27
27
  />
28
- {{ $t('my_playlists') }}
28
+ {{ $t('my_playlists') }} (3)
29
29
  <UiIcon
30
30
  name="arrow-right"
31
31
  size="16"
@@ -39,7 +39,7 @@
39
39
  :title="$t('user_playlists')"
40
40
  class="playlists__title"
41
41
  title-tag="h2"
42
- size="xl"
42
+ variant="section"
43
43
  />
44
44
 
45
45
  <SectionNavLinks
@@ -117,7 +117,7 @@ const breadcrumbs = [
117
117
  flex-direction: column;
118
118
  align-items: flex-start;
119
119
  row-gap: 12px;
120
- margin-bottom: 24px;
120
+ margin-bottom: 32px;
121
121
 
122
122
  @include from-br(xm) {
123
123
  flex-direction: row;
@@ -132,7 +132,7 @@ const breadcrumbs = [
132
132
  }
133
133
 
134
134
  .playlists__title {
135
- margin-bottom: 24px;
135
+ margin-bottom: 16px;
136
136
  }
137
137
 
138
138
  .playlists__navigation {