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.
- package/DOCS/CONVENTIONS.md +6 -1
- package/assets/scss/themes/dark/_theme.scss +13 -9
- package/assets/scss/themes/light/_theme.scss +13 -11
- package/components/card/video/index.vue +18 -180
- package/components/player/playlist.vue +1 -1
- package/components/ui/chips-micro.vue +298 -0
- package/components/{card/dropdown/trigger.vue → video/menu-trigger.vue} +6 -6
- package/components/{card/dropdown/index.vue → video/menu.vue} +7 -7
- package/composables/use-chips-overflow.ts +90 -0
- package/package.json +1 -1
package/DOCS/CONVENTIONS.md
CHANGED
|
@@ -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
|
|
228
|
-
$video-
|
|
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
|
|
282
|
-
$video-
|
|
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
|
-
<
|
|
134
|
-
v-if="!mini && !compact && !history
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
.
|
|
320
|
+
.video-menu-trigger > .ui-icon {
|
|
377
321
|
@media (hover: hover) {
|
|
378
322
|
transition: var(--transition) transform;
|
|
379
323
|
}
|
|
380
324
|
}
|
|
381
325
|
|
|
382
|
-
.
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
<!-- Кнопка меню
|
|
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, '
|
|
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 '
|
|
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
|
-
// Не
|
|
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('./
|
|
47
|
+
dropdown.value = (await import('./menu.vue')).default;
|
|
48
48
|
}
|
|
49
49
|
</script>
|
|
50
50
|
|
|
51
51
|
<style lang="scss">
|
|
52
|
-
.
|
|
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="
|
|
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="
|
|
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 '
|
|
107
|
-
import { AuthorizationApiService } from '
|
|
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
|
-
.
|
|
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
|
-
.
|
|
188
|
+
.video-menu__preview {
|
|
189
189
|
padding: 16px 32px 16px 16px;
|
|
190
|
-
border-bottom: 1px solid $video-
|
|
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
|
+
}
|