itube-specs 0.0.929 → 0.0.931

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.
@@ -27,11 +27,16 @@ Don't fork a component when a prop expresses the difference.
27
27
  |---|---|
28
28
  | text | `<span>` inline, `<p>` paragraph — never bare `<div>` |
29
29
  | structure | `header`, `nav`, `main`, `aside`, `footer`, `section`, `article` |
30
- | sequence of similar items | `<ul>`/`<ol>` + `<li>` — chip rows and tag clouds included |
31
30
  | action / navigation / collapsible | `<button>` / `<a>` / `<details>`+`<summary>` |
32
31
  | forms | `<label>`, `<fieldset>`, `<legend>`, native `<input>` types |
33
32
  | emphasis, figures | `<em>`/`<strong>`, `<figure>`/`<figcaption>` — not style-only spans |
34
33
 
34
+ **No `<ul>`/`<li>` for repeated rows.** Chip rows, tag clouds, link lists: render the items straight into a
35
+ flex container. `<li>` costs one element per item, and these rows repeat per card across grids of 40+ — the
36
+ node count is a measured TBT lever here. Don't substitute `role="list"`/`role="listitem"` either: the ARIA
37
+ role overrides the native one, so a link stops being announced as a link. Real `<ul>`/`<li>` stays correct for
38
+ one-off lists that aren't multiplied by a grid.
39
+
35
40
  ## TypeScript
36
41
 
37
42
  - Semicolons always. No code comments unless asked.
@@ -69,6 +69,17 @@ $chips-small-text: $color-zinc-400;
69
69
  $chips-bg-alphabet: $color-zinc-800-50;
70
70
  $chips-bg-alphabet--hover: $color-zinc-700;
71
71
 
72
+ // ─── Chips micro row (ряд микро-плашек на карточке: скролл и поповер «ещё») ───
73
+ $chips-micro-more-bg: $color-zinc-800-80;
74
+ $chips-micro-more-bg--hover: $color-zinc-700;
75
+ $chips-micro-more-bg--active: $color-indigo-600-20;
76
+ $chips-micro-more-text: $color-zinc-300;
77
+ $chips-micro-more-text--hover: $color-white;
78
+ $chips-micro-more-text--active: $color-indigo-300;
79
+ $chips-micro-popover-bg: $color-zinc-900;
80
+ $chips-micro-popover-item-bg: $color-zinc-800;
81
+ $chips-micro-popover-shadow: 0 12px 32px #{$mono-black-50};
82
+
72
83
  // ─── Input ───
73
84
  $input-bg: $color-zinc-800-50;
74
85
  $input-text: $color-white;
@@ -221,17 +232,10 @@ $video-card-action-active-icon: $color-white;
221
232
  $video-card-action-active-icon--hover: $color-white;
222
233
 
223
234
  // ─── Video card dropdown ───
224
- $video-card-dropdown-border: $color-zinc-800;
225
235
  $video-card-dropdown-size--hover: 1;
226
236
 
227
- // ─── Video card tags (ряд плашек моделей и поповер «ещё») ───
228
- $video-card-tags-more-bg: $color-zinc-800-80;
229
- $video-card-tags-more-bg--hover: $color-zinc-700;
230
- $video-card-tags-more-text: $color-zinc-300;
231
- $video-card-tags-more-text--hover: $color-white;
232
- $video-card-tags-popover-bg: $color-zinc-900;
233
- $video-card-tags-popover-border: $color-zinc-800;
234
- $video-card-tags-popover-shadow: 0 10px 15px -3px #{$mono-black-25}, 0 4px 6px -4px #{$mono-black-25};
237
+ // ─── Video menu (меню действий над видео) ───
238
+ $video-menu-preview-border: $color-zinc-800;
235
239
 
236
240
  // ─── Video info ───
237
241
  $video-info-item-text: $color-zinc-500-70;
@@ -79,6 +79,17 @@ $chips-small-bg: $color-gray-300;
79
79
  $chips-small-bg--hover: $color-gray-400;
80
80
  $chips-small-text: $color-gray-700;
81
81
 
82
+ // ─── Chips micro row (ряд микро-плашек на карточке: скролл и поповер «ещё») ───
83
+ $chips-micro-more-bg: $color-gray-300;
84
+ $chips-micro-more-bg--hover: $color-gray-400;
85
+ $chips-micro-more-bg--active: $color-indigo-600-20;
86
+ $chips-micro-more-text: $color-gray-700;
87
+ $chips-micro-more-text--hover: $color-gray-900;
88
+ $chips-micro-more-text--active: $color-indigo-700;
89
+ $chips-micro-popover-bg: $color-white;
90
+ $chips-micro-popover-item-bg: $color-gray-200;
91
+ $chips-micro-popover-shadow: 0 12px 32px #{$mono-black-25};
92
+
82
93
  // ─── Input ───
83
94
  $input-bg: $color-white;
84
95
  $input-text: $color-gray-900;
@@ -278,17 +289,8 @@ $video-card-action-active-border--hover: $color-red-600;
278
289
  $video-card-action-active-icon: $color-gray-900;
279
290
  $video-card-action-active-icon--hover: $color-white;
280
291
 
281
- // ─── Video card dropdown ───
282
- $video-card-dropdown-border: transparent;
283
-
284
- // ─── Video card tags (ряд плашек моделей и поповер «ещё») ───
285
- $video-card-tags-more-bg: $color-gray-300;
286
- $video-card-tags-more-bg--hover: $color-gray-400;
287
- $video-card-tags-more-text: $color-gray-700;
288
- $video-card-tags-more-text--hover: $color-gray-900;
289
- $video-card-tags-popover-bg: $color-white;
290
- $video-card-tags-popover-border: $color-gray-300;
291
- $video-card-tags-popover-shadow: 0 10px 15px -3px #{$mono-black-25}, 0 4px 6px -4px #{$mono-black-25};
292
+ // ─── Video menu (меню действий над видео) ───
293
+ $video-menu-preview-border: transparent;
292
294
 
293
295
  // ─── Video info ───
294
296
  $video-info-item-text: $color-gray-400;
@@ -130,34 +130,15 @@
130
130
  {{ remaining ? remainingText : watchedAgo }}
131
131
  </span>
132
132
  </p>
133
- <div
134
- v-if="!mini && !compact && !history && cardTags && cardTags.length > 0"
135
- ref="tagsRef"
133
+ <UiChipsMicro
134
+ v-if="!mini && !compact && !history"
135
+ v-model:open="isTagsOpen"
136
+ :items="card.models"
137
+ :label="t('show_all_models')"
136
138
  class="card-video__tags"
137
- >
138
- <div
139
- class="card-video__tags-wrapper"
140
- @click="isTagsOpen = false"
141
- >
142
- <UiChips
143
- v-for="(item, index) in cardTags"
144
- micro
145
- :key="`${card.guid}-tag-${index}`"
146
- :item="item"
147
- class="card-video__chips"
148
- is-link
149
- />
150
- </div>
151
- <button
152
- v-if="hasMoreTags"
153
- :aria-label="t('show_all_models')"
154
- :aria-expanded="isTagsOpen"
155
- class="card-video__tags-more"
156
- type="button"
157
- @click="isTagsOpen = !isTagsOpen"
158
- >+</button>
159
- </div>
160
- <CardDropdownTrigger
139
+ prefix="models"
140
+ />
141
+ <VideoMenuTrigger
161
142
  :card="card"
162
143
  :show-watch-later="canWatchLater"
163
144
  :show-favorites="canFavorite"
@@ -174,13 +155,9 @@
174
155
 
175
156
  <script setup lang="ts">
176
157
  import type { IVideoCard } from '../../../types';
177
- import { onClickOutside } from '@vueuse/core';
178
158
  import { convertString, getDuration } from '../../../runtime';
179
159
  import { CSS_BREAKPOINTS } from '../../../config/css-breakpoints';
180
160
 
181
- /** Потолок плашек в разметке: ряд скроллится, но неограниченный список раздувает DOM всей сетки */
182
- const TAGS_LIMIT = 8;
183
-
184
161
  const featureFlags = useRuntimeConfig().public.featureFlags;
185
162
 
186
163
  const props = defineProps<{
@@ -214,8 +191,6 @@ const { generateLink } = useGenerateLink();
214
191
 
215
192
  const pageName = useGetPureRouteName();
216
193
  const isChannelPage = computed(() => pageName === 'channels-channel');
217
- const isModelPage = computed(() => pageName.startsWith('models-slug'));
218
- const isSearchPage = computed(() => pageName.startsWith('search-slug'));
219
194
 
220
195
  const previewId = useId();
221
196
  const { activePreviewId, startPreviewByHover, stopPreviewByHover, startPreviewByTouch } = useVideoPreview();
@@ -277,44 +252,13 @@ const channelLink = computed(() => props.card.channelName
277
252
  ? generateLink(`/channels/${convertString().toSlug(props.card.channelName)}`)
278
253
  : undefined);
279
254
 
280
- const slug = useSlug();
281
-
282
- const cardTags = computed(() => {
283
- const query = slug.value.toLowerCase();
284
-
285
- const models = props.card.models
286
- ? [ ...props.card.models ].filter(item => !isModelPage.value || item.toLowerCase() !== query)
287
- : [];
288
-
289
- const searchRank = (item: string) => {
290
- if (!isSearchPage.value || !query) return 0;
291
- const name = item.toLowerCase();
292
- if (name === query) return 0;
293
- return name.includes(query) ? 1 : 2;
294
- };
295
-
296
- const sortModels = models.sort((a, b) =>
297
- searchRank(a) - searchRank(b) || a.localeCompare(b)
298
- );
299
-
300
- return sortModels.slice(0, TAGS_LIMIT).map(item => convertString().toChips(item, 'models'));
301
- });
302
-
303
- const tagsRef = ref();
304
255
  const isTagsOpen = ref(false);
305
-
306
- const hasMoreTags = computed(() => cardTags.value.length > 1);
307
-
308
- onClickOutside(tagsRef, () => {
309
- isTagsOpen.value = false;
310
- });
311
256
  </script>
312
257
 
313
258
  <style lang="scss">
314
259
  $inset-label: 8px;
315
260
  $layout-breakpoint: 250px;
316
261
  $progress-height: 4px;
317
- $tags-fade: 40px;
318
262
 
319
263
  // Колонка под меню карточки фиксирована, а не auto: иначе ширина зависит от того, отрисовано меню
320
264
  // или нет, и в момент подмены кнопки заголовок дёргается
@@ -373,13 +317,13 @@ $dropdown-column: 18px;
373
317
  }
374
318
  }
375
319
 
376
- .card-dropdown__trigger > .ui-icon {
320
+ .video-menu-trigger > .ui-icon {
377
321
  @media (hover: hover) {
378
322
  transition: var(--transition) transform;
379
323
  }
380
324
  }
381
325
 
382
- .card-dropdown__trigger:is(:hover, :focus-visible) > .ui-icon {
326
+ .video-menu-trigger:is(:hover, :focus-visible) > .ui-icon {
383
327
  @media (hover: hover) {
384
328
  transform: scale($video-card-dropdown-size--hover);
385
329
  }
@@ -451,7 +395,7 @@ $dropdown-column: 18px;
451
395
  }
452
396
 
453
397
  .--avatar .card-video__tags {
454
- grid-column: 2;
398
+ grid-column: 2 / -1;
455
399
  }
456
400
 
457
401
  @include to-br(sm) {
@@ -469,7 +413,7 @@ $dropdown-column: 18px;
469
413
  }
470
414
 
471
415
  .--no-channel .card-video__tags {
472
- grid-column: 1;
416
+ grid-column: 1 / -1;
473
417
  }
474
418
 
475
419
  .--no-channel .card-video__dropdown {
@@ -617,127 +561,21 @@ $dropdown-column: 18px;
617
561
  aspect-ratio: 16/9;
618
562
  }
619
563
 
620
- @property --card-video-tags-fade {
621
- syntax: '<length>';
622
- inherits: false;
623
- initial-value: 0;
624
- }
625
-
626
- /* Затемнение гаснет на последних 15% прокрутки — доехал до конца, подсказывать больше нечего */
627
- @keyframes card-video-tags-fade {
628
- 0%,
629
- 85% {
630
- --card-video-tags-fade: #{$tags-fade};
631
- }
632
-
633
- 100% {
634
- --card-video-tags-fade: 0px;
635
- }
636
- }
637
-
638
- @keyframes card-video-tags-more {
639
- 0%,
640
- 100% {
641
- visibility: visible;
642
- }
564
+ /* Карточка — свой контекст наложения из-за container-type, поповер плашек иначе уйдёт под соседнюю */
565
+ .card-video.--tags-open {
566
+ z-index: $z-index-dropdown;
643
567
  }
644
568
 
569
+ /* Ряд плашек лежит ниже «троеточия» — колонка под меню в этом ряду свободна, отдаём её плашкам */
645
570
  .card-video__tags {
646
- position: relative;
647
- grid-column: 1;
648
- min-height: var(--chips-height-micro);
649
- timeline-scope: --card-video-tags;
571
+ grid-column: 1 / -1;
650
572
 
651
573
  @include from-cq($layout-breakpoint) {
652
- grid-column: 2;
574
+ grid-column: 2 / -1;
653
575
  margin-top: 10px;
654
576
  }
655
577
  }
656
578
 
657
- .card-video__tags-wrapper {
658
- display: flex;
659
- flex-wrap: nowrap;
660
- align-items: center;
661
- gap: 8px;
662
- min-width: 0;
663
- overflow-x: auto;
664
- overscroll-behavior-inline: contain;
665
- scrollbar-width: none;
666
-
667
- &::-webkit-scrollbar {
668
- display: none;
669
- }
670
- }
671
-
672
- .card-video__tags-more {
673
- @include font-xs;
674
-
675
- position: absolute;
676
- inset-block: 0;
677
- right: 0;
678
- z-index: $z-index-dropdown;
679
- display: grid;
680
- place-content: center;
681
- padding-inline: 8px;
682
- visibility: hidden;
683
- color: $video-card-tags-more-text;
684
- background-color: $video-card-tags-more-bg;
685
- border-radius: $radius-6;
686
-
687
- @include hover(background-color, color) {
688
- background-color: $video-card-tags-more-bg--hover;
689
- color: $video-card-tags-more-text--hover;
690
- }
691
-
692
- @include to-br(sm) {
693
- display: none;
694
- }
695
- }
696
-
697
- /* Влезли плашки в ряд или нет, решает сам браузер: у непрокручиваемого контейнера scroll-таймлайн
698
- неактивен, анимации не применяются — ни затемнения, ни кнопки. Без замеров и без JS, то есть
699
- ещё до гидрации. Где scroll() не поддержан — ряд просто обрезается по краю. */
700
- @supports (animation-timeline: scroll()) {
701
- .card-video__tags-wrapper {
702
- scroll-timeline: --card-video-tags inline;
703
- animation: card-video-tags-fade linear both;
704
- animation-timeline: --card-video-tags;
705
- mask-image: linear-gradient(to right, currentcolor calc(100% - var(--card-video-tags-fade)), transparent);
706
- }
707
-
708
- .card-video__tags-more {
709
- animation: card-video-tags-more linear both;
710
- animation-timeline: --card-video-tags;
711
- }
712
- }
713
-
714
- /* Раскрытый список — тот же DOM: ряд перестаёт быть скроллом и разворачивается поповером поверх */
715
- .--tags-open .card-video__tags-wrapper {
716
- position: absolute;
717
- top: 0;
718
- left: 0;
719
- z-index: $z-index-dropdown;
720
- flex-wrap: wrap;
721
- width: max-content;
722
- max-width: 100%;
723
- padding: 8px $tags-fade 8px 8px;
724
- overflow: visible;
725
- background-color: $video-card-tags-popover-bg;
726
- border: 1px solid $video-card-tags-popover-border;
727
- border-radius: $radius-md;
728
- box-shadow: $video-card-tags-popover-shadow;
729
- mask-image: none;
730
- }
731
-
732
- .--tags-open .card-video__tags-more {
733
- visibility: visible;
734
- }
735
-
736
- /* Карточка — свой контекст наложения из-за container-type, поповер иначе уйдёт под соседнюю */
737
- .card-video.--tags-open {
738
- z-index: $z-index-dropdown;
739
- }
740
-
741
579
  .card-video__user {
742
580
  @include font-xs;
743
581
 
@@ -104,7 +104,7 @@
104
104
  class="player-playlist__card-mini"
105
105
  link
106
106
  />
107
- <CardDropdownTrigger
107
+ <VideoMenuTrigger
108
108
  :top="topDropdownIndex === index"
109
109
  :card="item"
110
110
  :show-remove="isOwner"
@@ -0,0 +1,298 @@
1
+ <!-- Ряд микро-плашек для карточек: скролл на мобиле, перенос с обрезкой по первой строке и «+» на десктопе.
2
+ ДЕМО-ВАРИАНТ: переполнение считает JS после отрисовки — показать дизайнеру запаздывание «+».
3
+ Не путать с UiExpandRow — тот про любые размеры чипсов и разворачивается вниз на месте. -->
4
+ <template>
5
+ <div
6
+ v-if="tags.length"
7
+ ref="rootRef"
8
+ :class="{'--open': open}"
9
+ class="ui-chips-micro"
10
+ >
11
+ <div
12
+ ref="listRef"
13
+ class="ui-chips-micro__list"
14
+ >
15
+ <UiChips
16
+ v-for="item in tags"
17
+ :key="item.title"
18
+ :item="item"
19
+ class="ui-chips-micro__item"
20
+ micro
21
+ is-link
22
+ />
23
+ </div>
24
+ <button
25
+ v-if="isOverflow"
26
+ :style="{ '--ui-chips-micro-more-left': `${moreLeft}px` }"
27
+ :aria-label="moreLabel"
28
+ :aria-expanded="open"
29
+ class="ui-chips-micro__more"
30
+ type="button"
31
+ @click="open = !open"
32
+ >+</button>
33
+ <div
34
+ v-if="open"
35
+ class="ui-chips-micro__popover"
36
+ @click="open = false"
37
+ >
38
+ <UiChips
39
+ v-for="item in hiddenTags"
40
+ :key="item.title"
41
+ :item="item"
42
+ class="ui-chips-micro__popover-item"
43
+ micro
44
+ is-link
45
+ />
46
+ </div>
47
+ </div>
48
+ </template>
49
+
50
+ <script setup lang="ts">
51
+ import { onClickOutside } from '@vueuse/core';
52
+ import { convertString } from '../../runtime';
53
+
54
+ const props = withDefaults(defineProps<{
55
+ /** Исходные названия сущностей — моделей, категорий, тегов */
56
+ items?: string[]
57
+ /** Префикс раздела для ссылки чипсы и для определения «своей» страницы */
58
+ prefix?: string
59
+ /** Потолок плашек в разметке: скрытые остаются краулеру, но неограниченный список раздувает DOM */
60
+ limit?: number
61
+ /** Подпись кнопки «+» для скринридера */
62
+ label?: string
63
+ }>(), {
64
+ prefix: 'models',
65
+ limit: 8,
66
+ });
67
+
68
+ const open = defineModel<boolean>('open', { default: false });
69
+
70
+ const { t } = useI18n();
71
+
72
+ const rootRef = ref();
73
+ const listRef = ref<HTMLElement>();
74
+
75
+ const pageName = useGetPureRouteName();
76
+
77
+ /** Страница самой сущности: свою же плашку в списке не показываем */
78
+ const isOwnPage = computed(() => pageName.startsWith(`${props.prefix}-slug`));
79
+ const isSearchPage = computed(() => pageName.startsWith('search-slug'));
80
+
81
+ const slug = useSlug();
82
+
83
+ const tags = computed(() => {
84
+ const query = slug.value.toLowerCase();
85
+
86
+ const items = props.items
87
+ ? [ ...props.items ].filter(item => !isOwnPage.value || item.toLowerCase() !== query)
88
+ : [];
89
+
90
+ const searchRank = (item: string) => {
91
+ if (!isSearchPage.value || !query) return 0;
92
+ const name = item.toLowerCase();
93
+ if (name === query) return 0;
94
+ return name.includes(query) ? 1 : 2;
95
+ };
96
+
97
+ const sorted = items.sort((a, b) =>
98
+ searchRank(a) - searchRank(b) || a.localeCompare(b)
99
+ );
100
+
101
+ return sorted.slice(0, props.limit).map(item => convertString().toChips(item, props.prefix));
102
+ });
103
+
104
+ const moreLabel = computed(() => props.label ?? t('show_all'));
105
+
106
+ const GAP = 8;
107
+
108
+ const isOverflow = ref(false);
109
+ const moreLeft = ref(0);
110
+ const visibleCount = ref(0);
111
+
112
+ /** В поповере только то, что не влезло в строку, — как в макете */
113
+ const hiddenTags = computed(() => tags.value.slice(visibleCount.value));
114
+
115
+ // Обрезку делает CSS (max-height в одну строку + overflow: hidden), здесь только читаем раскладку:
116
+ // вылез ли контент за строку и где кончается последняя влезшая плашка — «+» встаёт сразу за ней.
117
+ // В раскрытом виде список тянется под контент, замер дал бы ложное «влезло».
118
+ function measure() {
119
+ const el = listRef.value;
120
+ if (!el || open.value) return;
121
+
122
+ if (!isChipsOverflowWidth()) {
123
+ isOverflow.value = false;
124
+ return;
125
+ }
126
+
127
+ isOverflow.value = el.scrollHeight > el.clientHeight;
128
+ if (!isOverflow.value) return;
129
+
130
+ const chips = Array.from(el.children) as HTMLElement[];
131
+ const firstLineTop = chips[ 0 ]?.offsetTop ?? 0;
132
+
133
+ let lastInLine = chips[ 0 ];
134
+ let count = 0;
135
+ for (const chip of chips) {
136
+ if (chip.offsetTop !== firstLineTop) break;
137
+ lastInLine = chip;
138
+ count += 1;
139
+ }
140
+
141
+ visibleCount.value = count;
142
+ moreLeft.value = lastInLine ? lastInLine.offsetLeft + lastInLine.offsetWidth + GAP : 0;
143
+ }
144
+
145
+ useChipsOverflow(listRef, measure);
146
+
147
+ onClickOutside(rootRef, () => {
148
+ open.value = false;
149
+ });
150
+ </script>
151
+
152
+ <style lang="scss">
153
+ $fade-width: 40px;
154
+ $more-width: 32px;
155
+
156
+ @property --ui-chips-micro-fade-start {
157
+ syntax: '<length>';
158
+ inherits: false;
159
+ initial-value: 0;
160
+ }
161
+
162
+ @property --ui-chips-micro-fade-end {
163
+ syntax: '<length>';
164
+ inherits: false;
165
+ initial-value: 0;
166
+ }
167
+
168
+ /* Затемнение с той стороны, где есть неувиденное: слева проявляется, как только тронулся с места,
169
+ справа гаснет на подъезде к концу. Обе величины по нулям, пока ряд не прокручивается вовсе. */
170
+ @keyframes ui-chips-micro-fade {
171
+ 0% {
172
+ --ui-chips-micro-fade-start: 0px;
173
+ --ui-chips-micro-fade-end: #{$fade-width};
174
+ }
175
+
176
+ 15% {
177
+ --ui-chips-micro-fade-start: #{$fade-width};
178
+ }
179
+
180
+ 85% {
181
+ --ui-chips-micro-fade-end: #{$fade-width};
182
+ }
183
+
184
+ 100% {
185
+ --ui-chips-micro-fade-start: #{$fade-width};
186
+ --ui-chips-micro-fade-end: 0px;
187
+ }
188
+ }
189
+
190
+ .ui-chips-micro {
191
+ position: relative;
192
+ min-height: var(--chips-height-micro);
193
+ }
194
+
195
+ .ui-chips-micro__list {
196
+ display: flex;
197
+ flex-wrap: nowrap;
198
+ align-items: center;
199
+ gap: 8px;
200
+ min-width: 0;
201
+ overflow-x: auto;
202
+ overscroll-behavior-inline: contain;
203
+ scrollbar-width: none;
204
+
205
+ /* Десктоп: не скролл, а перенос, обрезанный по первой строке. Обрезка через overflow: hidden —
206
+ контейнер не прокручиваемый, колесо мыши не перехватывает. Место справа под «+» даёт паддинг. */
207
+ @include from-br(sm) {
208
+ flex-wrap: wrap;
209
+ align-content: flex-start;
210
+ max-height: var(--chips-height-micro);
211
+ padding-right: $more-width;
212
+ overflow: hidden;
213
+ }
214
+
215
+ &::-webkit-scrollbar {
216
+ display: none;
217
+ }
218
+ }
219
+
220
+ .ui-chips-micro__item {
221
+ flex-shrink: 0;
222
+ }
223
+
224
+ .ui-chips-micro__more {
225
+ @include font-xs;
226
+
227
+ position: absolute;
228
+ inset-block: 0;
229
+ left: var(--ui-chips-micro-more-left, 0);
230
+ z-index: $z-index-dropdown;
231
+ display: grid;
232
+ place-content: center;
233
+ padding-inline: 10px;
234
+ font-weight: 500;
235
+ color: $chips-micro-more-text;
236
+ background-color: $chips-micro-more-bg;
237
+ border-radius: $radius-6;
238
+
239
+ @include hover(background-color, color) {
240
+ background-color: $chips-micro-more-bg--hover;
241
+ color: $chips-micro-more-text--hover;
242
+ }
243
+
244
+ @include to-br(sm) {
245
+ display: none;
246
+ }
247
+ }
248
+
249
+ .--open .ui-chips-micro__more {
250
+ color: $chips-micro-more-text--active;
251
+ background-color: $chips-micro-more-bg--active;
252
+ }
253
+
254
+ /* Панель под рядом: только те плашки, что не влезли. Рисуется по клику, в свёрнутом виде
255
+ её в разметке нет — дублирования плашек на сетке не возникает. */
256
+ .ui-chips-micro__popover {
257
+ position: absolute;
258
+ top: 100%;
259
+ left: 0;
260
+ z-index: $z-index-dropdown;
261
+ display: flex;
262
+ flex-wrap: wrap;
263
+ gap: 8px;
264
+ width: max-content;
265
+ max-width: 100%;
266
+ margin-top: 8px;
267
+ padding: 8px;
268
+ background-color: $chips-micro-popover-bg;
269
+ border-radius: $radius-md;
270
+ box-shadow: $chips-micro-popover-shadow;
271
+ }
272
+
273
+ .ui-chips-micro__popover-item {
274
+ background-color: $chips-micro-popover-item-bg;
275
+ }
276
+
277
+ /* Затемнение края на мобиле по-прежнему без замеров: непрокручиваемый ряд — таймлайн неактивен */
278
+ @supports (animation-timeline: scroll()) {
279
+ .ui-chips-micro__list {
280
+ scroll-timeline: --ui-chips-micro inline;
281
+ animation: ui-chips-micro-fade linear both;
282
+ animation-timeline: --ui-chips-micro;
283
+ mask-image: linear-gradient(
284
+ to right,
285
+ transparent 0,
286
+ currentcolor var(--ui-chips-micro-fade-start),
287
+ currentcolor calc(100% - var(--ui-chips-micro-fade-end)),
288
+ transparent 100%
289
+ );
290
+
291
+ @include from-br(sm) {
292
+ animation: none;
293
+ mask-image: none;
294
+ }
295
+ }
296
+ }
297
+
298
+ </style>
@@ -1,4 +1,4 @@
1
- <!-- Кнопка меню карточки: до первого клика в DOM только она, чанк самого меню грузится по требованию -->
1
+ <!-- Кнопка меню видео: до первого клика в DOM только она, чанк самого меню грузится по требованию -->
2
2
  <template>
3
3
  <component
4
4
  v-if="dropdown"
@@ -10,7 +10,7 @@
10
10
  v-else
11
11
  v-bind="{ onClick: $attrs.onClick }"
12
12
  :aria-label="t('open_menu')"
13
- :class="[$attrs.class, 'card-dropdown__trigger']"
13
+ :class="[$attrs.class, 'video-menu-trigger']"
14
14
  type="button"
15
15
  @click="mountDropdown"
16
16
  >
@@ -23,7 +23,7 @@
23
23
 
24
24
  <script setup lang="ts">
25
25
  import type { Component } from 'vue';
26
- import type { IVideoCard } from '../../../types';
26
+ import type { IVideoCard } from '../../types';
27
27
 
28
28
  defineOptions({ inheritAttrs: false });
29
29
 
@@ -38,18 +38,18 @@ const props = defineProps<{
38
38
 
39
39
  const { t } = useI18n();
40
40
 
41
- // Не LazyCardDropdown: async-обёртка на первом рендере отдаёт коммент-заглушку вместо элемента,
41
+ // Не LazyVideoMenu: async-обёртка на первом рендере отдаёт коммент-заглушку вместо элемента,
42
42
  // колонка грида схлопывается и текст карточки успевает дёрнуться. Кладём уже загруженный компонент —
43
43
  // подмена кнопки на меню происходит одним патчем.
44
44
  const dropdown = shallowRef<Component | null>(null);
45
45
 
46
46
  async function mountDropdown() {
47
- dropdown.value = (await import('./index.vue')).default;
47
+ dropdown.value = (await import('./menu.vue')).default;
48
48
  }
49
49
  </script>
50
50
 
51
51
  <style lang="scss">
52
- .card-dropdown__trigger {
52
+ .video-menu-trigger {
53
53
  position: relative;
54
54
 
55
55
  &::after {
@@ -10,7 +10,7 @@
10
10
  <slot name="trigger">
11
11
  <button
12
12
  :aria-label="$t('open_menu')"
13
- class="card-dropdown__trigger"
13
+ class="video-menu-trigger"
14
14
  type="button"
15
15
  >
16
16
  <UiIcon
@@ -23,7 +23,7 @@
23
23
  <template #header>
24
24
  <CardVideoMini
25
25
  :card="card"
26
- class="card-dropdown__preview _to-md"
26
+ class="video-menu__preview _to-md"
27
27
  />
28
28
  </template>
29
29
  <template #items="className">
@@ -103,8 +103,8 @@
103
103
  </template>
104
104
 
105
105
  <script setup lang="ts">
106
- import type { IVideoCard } from '../../../types';
107
- import { AuthorizationApiService } from '../../../services/api/authorization.service';
106
+ import type { IVideoCard } from '../../types';
107
+ import { AuthorizationApiService } from '../../services/api/authorization.service';
108
108
 
109
109
  const props = defineProps<{
110
110
  card: IVideoCard
@@ -177,7 +177,7 @@ defineExpose({ openDropdown });
177
177
  </script>
178
178
 
179
179
  <style lang="scss">
180
- .card-dropdown__trigger {
180
+ .video-menu-trigger {
181
181
  position: relative;
182
182
 
183
183
  &::after {
@@ -185,8 +185,8 @@ defineExpose({ openDropdown });
185
185
  }
186
186
  }
187
187
 
188
- .card-dropdown__preview {
188
+ .video-menu__preview {
189
189
  padding: 16px 32px 16px 16px;
190
- border-bottom: 1px solid $video-card-dropdown-border;
190
+ border-bottom: 1px solid $video-menu-preview-border;
191
191
  }
192
192
  </style>
@@ -0,0 +1,90 @@
1
+ import type { Ref } from 'vue';
2
+ import { CSS_BREAKPOINTS } from '../config/css-breakpoints';
3
+
4
+ // Наблюдатель, медиа-проверка и ожидание шрифта — по одному на всю страницу, а не на карточку:
5
+ // иначе на сетке оказалось бы полсотни ResizeObserver'ов и столько же слушателей matchMedia.
6
+ // Лежит отдельным модулем, а не в <script> компонента: там eslint --fix уносит это в <script setup>,
7
+ // и общее состояние молча становится поэкземплярным.
8
+ let observer: ResizeObserver | null = null;
9
+ let mediaQuery: MediaQueryList | null = null;
10
+ let fontsWatched = false;
11
+
12
+ const measures = new Map<Element, () => void>();
13
+
14
+ function ensureObserver() {
15
+ if (!observer) {
16
+ observer = new ResizeObserver((entries) => {
17
+ for (const entry of entries) {
18
+ measures.get(entry.target)?.();
19
+ }
20
+ });
21
+ }
22
+
23
+ return observer;
24
+ }
25
+
26
+ function remeasureAll() {
27
+ for (const measure of measures.values()) {
28
+ measure();
29
+ }
30
+ }
31
+
32
+ function syncObserved() {
33
+ for (const el of measures.keys()) {
34
+ if (mediaQuery?.matches) {
35
+ ensureObserver().observe(el);
36
+ }
37
+ else {
38
+ ensureObserver().unobserve(el);
39
+ }
40
+ }
41
+ }
42
+
43
+ /** Ниже sm кнопки «ещё» нет вовсе — там не наблюдаем и раскладку не читаем совсем. */
44
+ export function isChipsOverflowWidth() {
45
+ if (!mediaQuery) {
46
+ mediaQuery = window.matchMedia(`(min-width: ${CSS_BREAKPOINTS.sm}px)`);
47
+ mediaQuery.addEventListener('change', () => {
48
+ syncObserved();
49
+ remeasureAll();
50
+ });
51
+ }
52
+
53
+ return mediaQuery.matches;
54
+ }
55
+
56
+ // Подмену шрифта наблюдатель не поймает: ширину ряда задаёт колонка грида, высоту режет max-height —
57
+ // от смены гарнитуры ни то, ни другое не меняется, а ширина плашек меняется. Пересчитываем разово.
58
+ function watchFonts() {
59
+ if (fontsWatched || !document.fonts) return;
60
+
61
+ fontsWatched = true;
62
+ document.fonts.ready.then(remeasureAll);
63
+ }
64
+
65
+ /**
66
+ * Подписывает ряд плашек на пересчёт переполнения: ресайз, смена размера экрана, подмена шрифта.
67
+ * @param elRef - ссылка на обрезанный по строке контейнер плашек
68
+ * @param measure - чтение раскладки этого ряда, вызывается уже после пересчёта браузером
69
+ */
70
+ export function useChipsOverflow(elRef: Ref<HTMLElement | undefined>, measure: () => void) {
71
+ onMounted(() => {
72
+ if (!elRef.value) return;
73
+
74
+ measures.set(elRef.value, measure);
75
+
76
+ if (isChipsOverflowWidth()) {
77
+ ensureObserver().observe(elRef.value);
78
+ watchFonts();
79
+ }
80
+
81
+ measure();
82
+ });
83
+
84
+ onBeforeUnmount(() => {
85
+ if (!elRef.value) return;
86
+
87
+ measures.delete(elRef.value);
88
+ observer?.unobserve(elRef.value);
89
+ });
90
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.929",
4
+ "version": "0.0.931",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {